Files
qdrant/tests/manual/test_memory_reporting.py
Andrey Vasnetsov b3fc4dfe4e deep memory reporting (#8606)
* 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>
2026-05-08 13:46:42 +02:00

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()