mirror of
https://github.com/qdrant/qdrant.git
synced 2026-07-23 11:11:00 -05:00
* Add mincore-based memory stats to MmapFile Add `resident_bytes()`, `disk_bytes()`, and `probe_memory_stats()` methods to `MmapFile` for measuring page cache residency via `mincore(2)`. This is the foundation for per-collection memory usage reporting. Also extract `page_size()` as a public function in `mmap::advice`, replacing the internal `PAGE_SIZE_MASK` with a direct page size cache. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * [AI] introduce trait for reporting memory usage per component * [AI] memory reporter implementation for vector storage * [AI] implement MemoryReporter for QuantizedVectors * [AI] implement MemoryReporter for VectorIndexEnum * Implement MemoryReporter for IdTrackerEnum with RAM estimation Add ram_usage_bytes() to all ID tracker types and their data structures: - PointMappings, CompressedPointMappings, CompressedVersions, CompressedInternalToExternal, CompressedExternalToInternal - MutableIdTracker, ImmutableIdTracker, InMemoryIdTracker All ID trackers load their data into RAM (none use mmap for working data). Files are reported as OnDisk (persistence only), actual RAM footprint is reported via extra_ram_bytes. Uses struct destructuring to ensure new fields trigger compile errors if not accounted for. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * [AI] implement MemoryReporter for PayloadStorageEnum and adjust FileStorageIntent * [AI] implement MemoryReporter for PayloadStorageEnum and adjust FileStorageIntent * [AI] implement MemoryReporter for payload indexes: in-ram structures memory consumtion computation + caching * [AI] implement MemoryReporter for payload indexes: in-ram structures memory consumtion computation + caching * [AI] segment-level memory usage report * [AI] Block 3: Aggregation Layer and Data Model + internal api for remote shard * [AI] REST API handler * fmt * [AI] clippy fixes * [AI] macos fix + proxy segment fix * [AI] make text index estimation a bit more correct * fix is_on_disk reporting for dense_vector_storage * fix after rebase * [AI] deep account for quantized vectors RAM usage + unify chunk size + shring volatile storage after load * remove debug log * cache in test * make manual test easier to run * rollback chunk size diff, but keep it for test only * review fixes * Use exhaustive match * Use div_ceil on bits everywhere It does not seem to be strictly necessary because the number of bits should already be a multiple of the used container size bytes. Still it's good practice to be careful with this calculation. * Improve heap size bytes for encoded product quantization vectors * Include vector stats for binary quantized vectors * In volatile chunked vectors, include heap allocated vector * Include rest of heap allocated structures for mutable map index * In mutable geo index, the hash map is also heap allocated * Update tests/manual/test_memory_reporting.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: timvisee <tim@visee.me> Co-authored-by: Tim Visée <tim+github@visee.me> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
294 lines
11 KiB
Python
294 lines
11 KiB
Python
"""
|
|
E2E test for memory usage reporting accuracy.
|
|
|
|
Loads a snapshot with various payload indexes and vector fields,
|
|
then removes components one by one, comparing the reported RAM usage
|
|
(from /collections/{name}/memory) against the actual change in
|
|
jemalloc allocated_bytes (from /telemetry).
|
|
|
|
Assumes Qdrant is already running on localhost:6333.
|
|
"""
|
|
|
|
import os
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import requests
|
|
|
|
SNAPSHOT_URL = "https://storage.googleapis.com/qdrant-benchmark-snapshots/all-components/all-payloads-5761161004893096-2026-04-12-13-53-30.snapshot"
|
|
COLLECTION_NAME = "memory_test"
|
|
BASE_URL = "http://localhost:6333"
|
|
CACHE_DIR = Path(__file__).parent / ".snapshot_cache"
|
|
|
|
|
|
def get_cached_snapshot() -> Path:
|
|
"""Download the snapshot once and cache it locally; return the local path."""
|
|
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
cached_path = CACHE_DIR / Path(SNAPSHOT_URL).name
|
|
if cached_path.exists() and cached_path.stat().st_size > 0:
|
|
print(f"Using cached snapshot: {cached_path} ({cached_path.stat().st_size} bytes)")
|
|
return cached_path
|
|
|
|
print(f"Downloading snapshot from {SNAPSHOT_URL}...")
|
|
tmp_path = cached_path.with_suffix(cached_path.suffix + ".tmp")
|
|
with requests.get(SNAPSHOT_URL, stream=True, timeout=600) as r:
|
|
r.raise_for_status()
|
|
with open(tmp_path, "wb") as f:
|
|
for chunk in r.iter_content(chunk_size=1024 * 1024):
|
|
if chunk:
|
|
f.write(chunk)
|
|
os.replace(tmp_path, cached_path)
|
|
print(f"Cached snapshot to {cached_path} ({cached_path.stat().st_size} bytes)")
|
|
return cached_path
|
|
|
|
|
|
def get_allocated_bytes() -> int:
|
|
"""Get jemalloc allocated_bytes from telemetry."""
|
|
resp = requests.get(f"{BASE_URL}/telemetry", params={"details_level": 2})
|
|
resp.raise_for_status()
|
|
return resp.json()["result"]["memory"]["allocated_bytes"]
|
|
|
|
|
|
def get_memory_report(collection_name: str) -> dict:
|
|
"""Get the collection memory report."""
|
|
resp = requests.get(f"{BASE_URL}/collections/{collection_name}/memory")
|
|
resp.raise_for_status()
|
|
return resp.json()["result"]
|
|
|
|
|
|
def get_collection_info(collection_name: str) -> dict:
|
|
"""Get collection info."""
|
|
resp = requests.get(f"{BASE_URL}/collections/{collection_name}")
|
|
resp.raise_for_status()
|
|
return resp.json()["result"]
|
|
|
|
|
|
def wait_for_green(collection_name: str, timeout: int = 120):
|
|
"""Wait for collection optimizer to finish (status green)."""
|
|
for _ in range(timeout):
|
|
info = get_collection_info(collection_name)
|
|
status = info.get("status")
|
|
if status == "green":
|
|
return
|
|
time.sleep(1)
|
|
raise TimeoutError(f"Collection {collection_name} did not reach green status in {timeout}s")
|
|
|
|
|
|
def delete_payload_index(collection_name: str, field_name: str):
|
|
"""Delete a payload index."""
|
|
resp = requests.delete(
|
|
f"{BASE_URL}/collections/{collection_name}/index/{field_name}",
|
|
params={"wait": "true"},
|
|
)
|
|
resp.raise_for_status()
|
|
|
|
|
|
def delete_vector_field(collection_name: str, vector_name: str):
|
|
"""Delete a named vector field entirely."""
|
|
resp = requests.delete(
|
|
f"{BASE_URL}/collections/{collection_name}/vectors/{vector_name}",
|
|
params={"wait": "true"},
|
|
)
|
|
resp.raise_for_status()
|
|
|
|
|
|
def fmt_bytes(b: int) -> str:
|
|
"""Format bytes as human-readable string."""
|
|
if abs(b) >= 1024 * 1024:
|
|
return f"{b / (1024 * 1024):.2f} MB"
|
|
if abs(b) >= 1024:
|
|
return f"{b / 1024:.2f} KB"
|
|
return f"{b} B"
|
|
|
|
|
|
def test_memory_reporting_accuracy():
|
|
"""
|
|
Load a snapshot, measure memory, remove components one by one,
|
|
and compare reported vs actual memory freed.
|
|
"""
|
|
# Verify server is up
|
|
resp = requests.get(f"{BASE_URL}/collections")
|
|
resp.raise_for_status()
|
|
print("Qdrant is running.")
|
|
|
|
# Clean up any leftover collection from a previous run
|
|
requests.delete(f"{BASE_URL}/collections/{COLLECTION_NAME}", params={"wait": "true"})
|
|
|
|
# Get snapshot from local cache (download once if needed)
|
|
snapshot_path = get_cached_snapshot()
|
|
|
|
# Upload snapshot to Qdrant
|
|
print(f"\nUploading snapshot to Qdrant...")
|
|
with open(snapshot_path, "rb") as f:
|
|
resp = requests.post(
|
|
f"{BASE_URL}/collections/{COLLECTION_NAME}/snapshots/upload",
|
|
files={"snapshot": (snapshot_path.name, f, "application/octet-stream")},
|
|
params={"wait": "true"},
|
|
timeout=600,
|
|
)
|
|
resp.raise_for_status()
|
|
print("Snapshot recovered.")
|
|
|
|
wait_for_green(COLLECTION_NAME)
|
|
|
|
# Discover what's in the collection
|
|
info = get_collection_info(COLLECTION_NAME)
|
|
payload_schema = info.get("payload_schema", {})
|
|
vectors_config = info.get("config", {}).get("params", {}).get("vectors", {})
|
|
sparse_vectors_config = info.get("config", {}).get("params", {}).get("sparse_vectors", {})
|
|
|
|
indexed_fields = [
|
|
name for name, schema in payload_schema.items()
|
|
if schema.get("points", 0) > 0
|
|
]
|
|
|
|
dense_vector_names = list(vectors_config.keys()) if isinstance(vectors_config, dict) else []
|
|
sparse_vector_names = list(sparse_vectors_config.keys()) if isinstance(sparse_vectors_config, dict) else []
|
|
|
|
print("\nCollection has:")
|
|
print(f" Indexed payload fields: {indexed_fields}")
|
|
print(f" Dense vector fields: {dense_vector_names}")
|
|
print(f" Sparse vector fields: {sparse_vector_names}")
|
|
|
|
# Get initial memory state
|
|
report = get_memory_report(COLLECTION_NAME)
|
|
initial_allocated = get_allocated_bytes()
|
|
|
|
print(f"\nInitial jemalloc allocated: {fmt_bytes(initial_allocated)}")
|
|
print(f"Reported total RAM: {fmt_bytes(report['total']['ram_bytes'])}")
|
|
print(f"Reported total cached: {fmt_bytes(report['total']['cached_bytes'])}")
|
|
|
|
results = []
|
|
|
|
# Build a lookup of reported memory per component
|
|
payload_index_memory = {
|
|
pi["name"]: pi["usage"]["ram_bytes"]
|
|
for pi in report.get("payload_index", [])
|
|
}
|
|
vector_memory = {}
|
|
for v in report.get("vectors", []):
|
|
total_ram = v["storage"]["ram_bytes"] + v["index"]["ram_bytes"]
|
|
if v.get("quantized"):
|
|
total_ram += v["quantized"]["ram_bytes"]
|
|
vector_memory[v["name"]] = total_ram
|
|
|
|
sparse_vector_memory = {}
|
|
for sv in report.get("sparse_vectors", []):
|
|
total_ram = sv["storage"]["ram_bytes"] + sv["index"]["ram_bytes"]
|
|
sparse_vector_memory[sv["name"]] = total_ram
|
|
|
|
# Remove payload indexes one by one
|
|
for field_name in indexed_fields:
|
|
estimated = payload_index_memory.get(field_name, 0)
|
|
before = get_allocated_bytes()
|
|
|
|
print(f"\nDeleting payload index '{field_name}' (estimated RAM: {fmt_bytes(estimated)})...")
|
|
delete_payload_index(COLLECTION_NAME, field_name)
|
|
wait_for_green(COLLECTION_NAME)
|
|
time.sleep(1)
|
|
|
|
after = get_allocated_bytes()
|
|
actual_freed = before - after
|
|
|
|
error_abs = abs(actual_freed - estimated)
|
|
error_pct = (error_abs / estimated * 100) if estimated > 0 else float("inf")
|
|
|
|
results.append({
|
|
"component": f"payload_index/{field_name}",
|
|
"estimated": estimated,
|
|
"actual_freed": actual_freed,
|
|
"error_abs": error_abs,
|
|
"error_pct": error_pct,
|
|
})
|
|
|
|
print(f" Actual freed: {fmt_bytes(actual_freed)}, Error: {fmt_bytes(error_abs)} ({error_pct:.1f}%)")
|
|
|
|
# Remove dense vector fields one by one
|
|
for vec_name in dense_vector_names:
|
|
estimated = vector_memory.get(vec_name, 0)
|
|
before = get_allocated_bytes()
|
|
|
|
print(f"\nDeleting dense vector '{vec_name}' (estimated RAM: {fmt_bytes(estimated)})...")
|
|
delete_vector_field(COLLECTION_NAME, vec_name)
|
|
wait_for_green(COLLECTION_NAME)
|
|
time.sleep(1)
|
|
|
|
after = get_allocated_bytes()
|
|
actual_freed = before - after
|
|
|
|
error_abs = abs(actual_freed - estimated)
|
|
error_pct = (error_abs / estimated * 100) if estimated > 0 else float("inf")
|
|
|
|
results.append({
|
|
"component": f"vector/{vec_name}",
|
|
"estimated": estimated,
|
|
"actual_freed": actual_freed,
|
|
"error_abs": error_abs,
|
|
"error_pct": error_pct,
|
|
})
|
|
|
|
print(f" Actual freed: {fmt_bytes(actual_freed)}, Error: {fmt_bytes(error_abs)} ({error_pct:.1f}%)")
|
|
|
|
# Remove sparse vector fields one by one
|
|
for sv_name in sparse_vector_names:
|
|
estimated = sparse_vector_memory.get(sv_name, 0)
|
|
before = get_allocated_bytes()
|
|
|
|
print(f"\nDeleting sparse vector '{sv_name}' (estimated RAM: {fmt_bytes(estimated)})...")
|
|
delete_vector_field(COLLECTION_NAME, sv_name)
|
|
wait_for_green(COLLECTION_NAME)
|
|
time.sleep(1)
|
|
|
|
after = get_allocated_bytes()
|
|
actual_freed = before - after
|
|
|
|
error_abs = abs(actual_freed - estimated)
|
|
error_pct = (error_abs / estimated * 100) if estimated > 0 else float("inf")
|
|
|
|
results.append({
|
|
"component": f"sparse_vector/{sv_name}",
|
|
"estimated": estimated,
|
|
"actual_freed": actual_freed,
|
|
"error_abs": error_abs,
|
|
"error_pct": error_pct,
|
|
})
|
|
|
|
print(f" Actual freed: {fmt_bytes(actual_freed)}, Error: {fmt_bytes(error_abs)} ({error_pct:.1f}%)")
|
|
|
|
# Print summary table
|
|
print("\n" + "=" * 100)
|
|
print(f"{'Component':<40} {'Estimated':>12} {'Actual Freed':>14} {'Error':>12} {'Error %':>10}")
|
|
print("-" * 100)
|
|
for r in results:
|
|
print(
|
|
f"{r['component']:<40} "
|
|
f"{fmt_bytes(r['estimated']):>12} "
|
|
f"{fmt_bytes(r['actual_freed']):>14} "
|
|
f"{fmt_bytes(r['error_abs']):>12} "
|
|
f"{r['error_pct']:>9.1f}%"
|
|
)
|
|
print("=" * 100)
|
|
|
|
# Final check: remaining allocated should be close to initial minus sum of reported
|
|
final_allocated = get_allocated_bytes()
|
|
total_estimated_freed = sum(r["estimated"] for r in results)
|
|
total_actual_freed = initial_allocated - final_allocated
|
|
|
|
print(f"\nTotal estimated freed: {fmt_bytes(total_estimated_freed)}")
|
|
print(f"Total actual freed: {fmt_bytes(total_actual_freed)}")
|
|
|
|
total_error = abs(total_actual_freed - total_estimated_freed)
|
|
total_error_pct = (total_error / total_estimated_freed * 100) if total_estimated_freed > 0 else float("inf")
|
|
print(f"Total error: {fmt_bytes(total_error)} ({total_error_pct:.1f}%)")
|
|
|
|
# Assert overall error is within 30%
|
|
assert total_error_pct < 30, (
|
|
f"Total memory reporting error {total_error_pct:.1f}% exceeds 30% threshold. "
|
|
f"Estimated {fmt_bytes(total_estimated_freed)} freed, "
|
|
f"actual {fmt_bytes(total_actual_freed)} freed."
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
test_memory_reporting_accuracy()
|