Files
qdrant/lib/segment/tests/integration/gpu_hnsw_test.rs
Andrey Vasnetsov ab0d3ecc62 Add unified memory: cold|cached|pinned placement parameter for collection components (#9684)
* Add unified `memory: cold|cached|pinned` placement parameter for collection components

Introduce a single `memory` parameter that controls how each collection
component's data is held in RAM, replacing the inconsistent zoo of
`on_disk` / `always_ram` / `on_disk_payload` flags:

- `cold`: not pre-loaded from disk, cached with usage
- `cached`: pre-populated into page cache on load, evictable under pressure
- `pinned`: materialized on heap, never evicted by cache pressure

The parameter is available on dense vectors, HNSW config, all quantization
configs, the sparse index, all payload field index types, and payload
storage (as a new `payload: { memory }` sub-object on collection params).
When set, it overrides the deprecated legacy flag; when unset, behavior is
unchanged. Legacy flags are marked deprecated (Rust + proto) but keep
working; conflicts are resolved in favor of `memory` with a warning.

New capabilities enabled by the tri-state model:
- HNSW graph links can be pinned (first production caller of the existing
  `GraphLinksResidency::Pinned`)
- sparse mmap index, quantized vectors and on-disk payload field indexes
  gain a `cached` tier (mmap + populate on open)

`pinned` is rejected by API validation for components without a heap
variant (dense vector storage, payload storage). Low-memory mode degrades
placements at load time via `Memory::clamp_to_low_memory`, matching the
existing `prefer_disk`/`skip_populate` behavior. Effective-placement
comparison in the config-mismatch optimizer avoids spurious rebuilds when
the same placement is expressed through the new parameter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix gpu-gated tests for the new `memory` field

CI clippy runs with --all-features, which compiles the gpu-gated tests
that were missed locally: add the `memory` field to config literals and
allow deprecated placement params, same as in the rest of the tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add OpenAPI tests for memory placement, keep sparse config downgrade-clean

- OpenAPI tests: create/update collections with `memory` on every component,
  assert the parameters are echoed in collection info, assert legacy-only
  collections expose no new fields, and assert `pinned` is rejected (422)
  for dense vector storage and payload storage on both create and update.
- Persist only the explicitly requested `memory` parameter in
  `sparse_index_config.json` instead of the legacy-resolved placement, so
  configurations using only the deprecated `on_disk` flag keep byte-identical
  files that older Qdrant versions load without unknown fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Validate collection meta ops at construction, not only in the API layer

The `memory: pinned` rejection for dense vectors and payload storage
lived in `Validate` impls on the internal request types, which only ran
through the REST actix extractor. gRPC validates just the proto message,
so a gRPC client could persist `pinned` where it is not supported and
have it silently treated as `cached`.

Run the derived validation in `CreateCollectionOperation::new` and
`UpdateCollectionOperation::new` instead: the constructors are the
common chokepoint for all API paths, before the operation is proposed
to consensus. This covers every validator on these types, not just the
`memory` checks, and keeps consensus-apply unaffected so mixed-version
clusters never reject already-committed operations.

`UpdateCollectionOperation::new` becomes fallible; `remove_replica` now
uses `new_empty` since it carries no user config. Regression tests drive
the gRPC conversion path and assert `InvalidArgument` for `pinned` on
create and update, with `cold`/`cached` accepted as a control.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 14:32:51 +02:00

337 lines
11 KiB
Rust

// Deprecated storage placement params (`on_disk`, `always_ram`, `on_disk_payload`) are still
// handled here for backward compatibility with the new `memory` parameter
#![allow(deprecated)]
use std::collections::HashSet;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use common::budget::ResourcePermit;
use common::counter::hardware_counter::HardwareCounterCell;
use common::flags::FeatureFlags;
use common::progress_tracker::ProgressTracker;
use common::types::ScoredPointOffset;
use ordered_float::OrderedFloat;
use parking_lot::Mutex;
use rand::prelude::StdRng;
use rand::{RngExt, SeedableRng};
use segment::data_types::vectors::{DEFAULT_VECTOR_NAME, QueryVector, only_default_vector};
use segment::entry::entry_point::SegmentEntry;
use segment::fixtures::payload_fixtures::{random_int_payload, random_vector};
use segment::index::hnsw_index::get_num_indexing_threads;
use segment::index::hnsw_index::gpu::gpu_devices_manager::LockedGpuDevice;
use segment::index::hnsw_index::gpu::set_gpu_groups_count;
use segment::index::hnsw_index::hnsw::{HNSWIndex, HnswIndexOpenArgs};
use segment::index::{PayloadIndex, VectorIndexRead};
use segment::json_path::JsonPath;
use segment::payload_json;
use segment::segment::Segment;
use segment::segment_constructor::VectorIndexBuildArgs;
use segment::segment_constructor::simple_segment_constructor::build_simple_segment;
use segment::types::{
Condition, Distance, FieldCondition, Filter, HnswConfig, HnswGlobalConfig, PayloadSchemaType,
Range, SearchParams, SeqNumberType,
};
use tempfile::Builder;
fn build_hnsw(
path: &Path,
segment: &Segment,
hnsw_config: HnswConfig,
gpu_device: Option<&LockedGpuDevice>,
stopped: &AtomicBool,
) -> HNSWIndex {
let permit_cpu_count = get_num_indexing_threads(hnsw_config.max_indexing_threads);
let permit = Arc::new(ResourcePermit::dummy(permit_cpu_count as u32));
let mut build_rng = StdRng::seed_from_u64(100);
let vector_storage = &segment.vector_data[DEFAULT_VECTOR_NAME].vector_storage;
let quantized_vectors = &segment.vector_data[DEFAULT_VECTOR_NAME].quantized_vectors;
HNSWIndex::build(
HnswIndexOpenArgs {
path,
id_tracker: segment.id_tracker.clone(),
vector_storage: vector_storage.clone(),
quantized_vectors: quantized_vectors.clone(),
payload_index: segment.payload_index.clone(),
hnsw_config,
},
VectorIndexBuildArgs {
permit,
old_indices: &[],
gpu_device,
rng: &mut build_rng,
stopped,
hnsw_global_config: &HnswGlobalConfig::default(),
feature_flags: FeatureFlags::default(),
progress: ProgressTracker::new_for_test(),
},
)
.unwrap()
}
fn measure_accuracy(
hnsw_index: &HNSWIndex,
queries: &[(QueryVector, Filter)],
ground_truth: &[Vec<Vec<ScoredPointOffset>>],
ef: usize,
top: usize,
) -> usize {
let search_params = SearchParams {
hnsw_ef: Some(ef),
..Default::default()
};
queries
.iter()
.zip(ground_truth)
.filter(|((query, filter), truth)| {
let result = hnsw_index
.search(
&[query],
Some(filter),
top,
Some(&search_params),
&Default::default(),
)
.unwrap();
// Compare by point IDs only, ignoring score differences and ordering
let result_ids: HashSet<_> = result[0].iter().map(|s| s.idx).collect();
let truth_ids: HashSet<_> = truth[0].iter().map(|s| s.idx).collect();
result_ids == truth_ids
})
.count()
}
fn create_test_segment(
dir: &Path,
dim: usize,
distance: Distance,
num_vectors: u64,
int_key: &str,
num_payload_values: usize,
with_payload_index: bool,
) -> Segment {
let mut rng = StdRng::seed_from_u64(42);
let hw_counter = HardwareCounterCell::new();
let mut segment = build_simple_segment(dir, dim, distance).unwrap();
for n in 0..num_vectors {
let idx = n.into();
let vector = random_vector(&mut rng, dim);
let int_payload = random_int_payload(&mut rng, num_payload_values..=num_payload_values);
let payload = payload_json! {int_key: int_payload};
segment
.upsert_point(
n as SeqNumberType,
idx,
only_default_vector(&vector),
&hw_counter,
)
.unwrap();
segment
.set_full_payload(n as SeqNumberType, idx, &payload, &hw_counter)
.unwrap();
}
if with_payload_index {
segment
.payload_index
.borrow_mut()
.set_indexed(
&JsonPath::new(int_key),
PayloadSchemaType::Integer,
&hw_counter,
)
.unwrap();
}
segment
}
#[test]
fn test_gpu_filterable_hnsw() {
let _ = env_logger::builder()
.is_test(true)
.filter_level(log::LevelFilter::Trace)
.try_init();
let stopped = AtomicBool::new(false);
let dim = 8;
let m = 8;
let num_vectors: u64 = 10_000;
let ef = 32;
let ef_construct = 16;
let distance = Distance::Cosine;
let full_scan_threshold = 32; // KB
let num_payload_values = 2;
let int_key = "int";
let gpu_groups = 64; // limit gpu parallelism for small data
let hnsw_config = HnswConfig {
m,
ef_construct,
full_scan_threshold,
max_indexing_threads: 2,
on_disk: Some(false),
memory: None,
payload_m: None,
inline_storage: None,
};
// Pre-generate queries
let top = 3;
let attempts = 100;
let mut query_rng = StdRng::seed_from_u64(12345);
let queries: Vec<(QueryVector, Filter)> = (0..attempts)
.map(|_| {
let query: QueryVector = random_vector(&mut query_rng, dim).into();
let range_size = 40;
let left_range = query_rng.random_range(0..400);
let right_range = left_range + range_size;
let filter = Filter::new_must(Condition::Field(FieldCondition::new_range(
JsonPath::new(int_key),
Range {
lt: None,
gt: None,
gte: Some(OrderedFloat::from(f64::from(left_range))),
lte: Some(OrderedFloat::from(f64::from(right_range))),
},
)));
(query, filter)
})
.collect();
// GPU device setup (shared across GPU builds)
let gpu_instance = gpu::GPU_TEST_INSTANCE.clone();
let gpu_device = Mutex::new(
gpu::Device::new(gpu_instance.clone(), &gpu_instance.physical_devices()[0]).unwrap(),
);
set_gpu_groups_count(Some(gpu_groups));
// == Build without payload index (fresh segment) ==
let seg_dir_no_idx = Builder::new().prefix("seg_no_idx").tempdir().unwrap();
let segment_no_idx = create_test_segment(
seg_dir_no_idx.path(),
dim,
distance,
num_vectors,
int_key,
num_payload_values,
false,
);
// Compute ground truth using exact (plain) search
let ground_truth: Vec<_> = queries
.iter()
.map(|(query, filter)| {
segment_no_idx.vector_data[DEFAULT_VECTOR_NAME]
.vector_index
.borrow()
.search(&[query], Some(filter), top, None, &Default::default())
.unwrap()
})
.collect();
let hnsw_dir_cpu_no_idx = Builder::new().prefix("hnsw_cpu_no_idx").tempdir().unwrap();
let hnsw_cpu_no_idx = build_hnsw(
hnsw_dir_cpu_no_idx.path(),
&segment_no_idx,
hnsw_config,
None,
&stopped,
);
let hnsw_dir_gpu_no_idx = Builder::new().prefix("hnsw_gpu_no_idx").tempdir().unwrap();
let hnsw_gpu_no_idx = {
let locked = LockedGpuDevice::new(gpu_device.lock());
build_hnsw(
hnsw_dir_gpu_no_idx.path(),
&segment_no_idx,
hnsw_config,
Some(&locked),
&stopped,
)
};
// == Build with payload index (fresh segment) ==
let seg_dir_idx = Builder::new().prefix("seg_idx").tempdir().unwrap();
let segment_idx = create_test_segment(
seg_dir_idx.path(),
dim,
distance,
num_vectors,
int_key,
num_payload_values,
true,
);
let hnsw_dir_cpu_idx = Builder::new().prefix("hnsw_cpu_idx").tempdir().unwrap();
let hnsw_cpu_idx = build_hnsw(
hnsw_dir_cpu_idx.path(),
&segment_idx,
hnsw_config,
None,
&stopped,
);
let hnsw_dir_gpu_idx = Builder::new().prefix("hnsw_gpu_idx").tempdir().unwrap();
let hnsw_gpu_idx = {
let locked = LockedGpuDevice::new(gpu_device.lock());
build_hnsw(
hnsw_dir_gpu_idx.path(),
&segment_idx,
hnsw_config,
Some(&locked),
&stopped,
)
};
// == Measure accuracies ==
let hits_cpu_no_idx = measure_accuracy(&hnsw_cpu_no_idx, &queries, &ground_truth, ef, top);
let hits_cpu_idx = measure_accuracy(&hnsw_cpu_idx, &queries, &ground_truth, ef, top);
let hits_gpu_no_idx = measure_accuracy(&hnsw_gpu_no_idx, &queries, &ground_truth, ef, top);
let hits_gpu_idx = measure_accuracy(&hnsw_gpu_idx, &queries, &ground_truth, ef, top);
eprintln!("CPU without payload index: {hits_cpu_no_idx}/{attempts}");
eprintln!("CPU with payload index: {hits_cpu_idx}/{attempts}");
eprintln!("GPU without payload index: {hits_gpu_no_idx}/{attempts}");
eprintln!("GPU with payload index: {hits_gpu_idx}/{attempts}");
let min_diff = 5;
assert!(
hits_cpu_idx >= hits_cpu_no_idx + min_diff,
"Payload index should improve accuracy: cpu_idx={hits_cpu_idx} < cpu_no_idx={hits_cpu_no_idx} + {min_diff}"
);
assert!(
hits_gpu_idx >= hits_gpu_no_idx + min_diff,
"Payload index should improve accuracy: gpu_idx={hits_gpu_idx} < gpu_no_idx={hits_gpu_no_idx} + {min_diff}"
);
// GPU accuracy should be roughly the same as CPU
let max_diff = 5;
let diff_no_idx = (hits_cpu_no_idx as i64 - hits_gpu_no_idx as i64).unsigned_abs();
let diff_idx = (hits_cpu_idx as i64 - hits_gpu_idx as i64).unsigned_abs();
assert!(
diff_no_idx <= max_diff,
"GPU accuracy should be close to CPU (no index): cpu={hits_cpu_no_idx}, gpu={hits_gpu_no_idx}, diff={diff_no_idx}"
);
assert!(
diff_idx <= max_diff,
"GPU accuracy should be close to CPU (with index): cpu={hits_cpu_idx}, gpu={hits_gpu_idx}, diff={diff_idx}"
);
// Reset global GPU groups state to avoid leaking to other tests
set_gpu_groups_count(None);
}