Skip to content

Commit b6f8936

Browse files
perf(index): avoid HEAD call when opening vector indexes (#7064)
Closes #6944 ## Problem Opening a vector index made an extra network request to find out how big the index file was. We do not need that request. The file size is already saved in the manifest, in `IndexMetadata.files`. `Dataset::open_vector_index` opens `index.idx` just to read the footer and work out the format version. The plain `open` call does not know the file size, so the reader sends a HEAD request to get it. This happens for every vector index, not only HNSW. On the modern reader path the file is then opened a second time with the size already known, so the first HEAD was wasted. The same wasted HEAD also happened on the HNSW auxiliary file open (the case named in the issue), on the legacy remap path, and on legacy detail inference. ## Changes Added one small helper, `open_index_file`. It reads the size from the manifest (`IndexMetadata::file_size_map()`) and opens the file with `open_with_size`. If the size is not recorded, which is the case for older indices, it falls back to the plain `open`. The helper is now used everywhere a vector index file is opened: - `index.rs`, the main open that detects the format version (IVF_PQ, IVF_RQ, FLAT). This also removes a duplicate `file_size_map()` call in the same path. - `vector.rs`, the `IVF_HNSW_PQ` and `IVF_HNSW_SQ` auxiliary file opens. - `ivf.rs`, the legacy v1 `remap_index_file`. - `details.rs`, the legacy `infer_vector_index_details` fallback. ## Testing - New test `test_open_index_file_skips_head_when_size_known`. It wraps the store in a proxy that counts metadata reads against the index file. The result is 0 HEAD requests when the size is known and 1 HEAD on the older fallback path. - `cargo fmt --all` - `cargo clippy -p lance --tests -- -D warnings`, clean. - `cargo test -p lance --lib index::vector::`, 208 passed.
1 parent 466405f commit b6f8936

4 files changed

Lines changed: 182 additions & 6 deletions

File tree

rust/lance/src/index.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1877,7 +1877,15 @@ impl DatasetIndexInternalExt for Dataset {
18771877
let frag_reuse_index = self.open_frag_reuse_index(metrics).await?;
18781878
let index_dir = self.indice_files_dir(&index_meta)?;
18791879
let index_file = index_dir.clone().join(uuid).join(INDEX_FILE_NAME);
1880-
let reader: Arc<dyn Reader> = object_store.open(&index_file).await?.into();
1880+
let file_sizes = index_meta.file_size_map();
1881+
let reader: Arc<dyn Reader> = vector::open_index_file(
1882+
object_store.as_ref(),
1883+
&index_file,
1884+
INDEX_FILE_NAME,
1885+
&file_sizes,
1886+
)
1887+
.await?
1888+
.into();
18811889

18821890
let tailing_bytes = read_last_block(reader.as_ref()).await?;
18831891
let (major_version, minor_version) = read_version(&tailing_bytes)?;
@@ -1944,7 +1952,6 @@ impl DatasetIndexInternalExt for Dataset {
19441952
self.object_store.clone(),
19451953
SchedulerConfig::max_bandwidth(&self.object_store),
19461954
);
1947-
let file_sizes = index_meta.file_size_map();
19481955
let cached_size = file_sizes
19491956
.get(INDEX_FILE_NAME)
19501957
.map(|&size| CachedFileSize::new(size))

rust/lance/src/index/vector.rs

Lines changed: 153 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ use lance_index::vector::{
5252
sq::{ScalarQuantizer, builder::SQBuildParams},
5353
};
5454
use lance_index::{INDEX_AUXILIARY_FILE_NAME, INDEX_METADATA_SCHEMA_KEY, IndexType};
55+
use lance_io::object_store::ObjectStore;
5556
use lance_io::traits::Reader;
5657
use lance_linalg::distance::*;
5758
use lance_table::format::{IndexMetadata, list_index_files_with_sizes};
@@ -1588,6 +1589,24 @@ pub(crate) async fn open_vector_index(
15881589
Ok(idx)
15891590
}
15901591

1592+
/// Open an index file without a HEAD request when the size is already known.
1593+
///
1594+
/// `file_sizes` maps a file name to its size in bytes (see
1595+
/// `IndexMetadata::file_size_map`). If `file_name` is missing, which is the case
1596+
/// for older indices that did not record sizes, this falls back to `open`, which
1597+
/// issues a HEAD to learn the size.
1598+
pub(crate) async fn open_index_file(
1599+
object_store: &ObjectStore,
1600+
path: &Path,
1601+
file_name: &str,
1602+
file_sizes: &HashMap<String, u64>,
1603+
) -> Result<Box<dyn Reader>> {
1604+
match file_sizes.get(file_name) {
1605+
Some(&size) => object_store.open_with_size(path, size as usize).await,
1606+
None => object_store.open(path).await,
1607+
}
1608+
}
1609+
15911610
#[instrument(level = "debug", skip(dataset, reader))]
15921611
pub(crate) async fn open_vector_index_v2(
15931612
dataset: Arc<Dataset>,
@@ -1612,11 +1631,18 @@ pub(crate) async fn open_vector_index_v2(
16121631
.ok_or_else(|| Error::index(format!("Index with id {} does not exist", uuid)))?;
16131632
let index_dir = dataset.indice_files_dir(&index_meta)?;
16141633
let object_store = dataset.object_store_for_index(&index_meta).await?;
1634+
let file_sizes = index_meta.file_size_map();
16151635

16161636
let index: Arc<dyn VectorIndex> = match index_metadata.index_type.as_str() {
16171637
"IVF_HNSW_PQ" => {
16181638
let aux_path = index_dir.clone().join(uuid).join(INDEX_AUXILIARY_FILE_NAME);
1619-
let aux_reader = object_store.open(&aux_path).await?;
1639+
let aux_reader = open_index_file(
1640+
object_store.as_ref(),
1641+
&aux_path,
1642+
INDEX_AUXILIARY_FILE_NAME,
1643+
&file_sizes,
1644+
)
1645+
.await?;
16201646

16211647
let ivf_data = IvfModel::load(&reader).await?;
16221648
let options = HNSWIndexOptions { use_residual: true };
@@ -1643,7 +1669,13 @@ pub(crate) async fn open_vector_index_v2(
16431669

16441670
"IVF_HNSW_SQ" => {
16451671
let aux_path = index_dir.clone().join(uuid).join(INDEX_AUXILIARY_FILE_NAME);
1646-
let aux_reader = object_store.open(&aux_path).await?;
1672+
let aux_reader = open_index_file(
1673+
object_store.as_ref(),
1674+
&aux_path,
1675+
INDEX_AUXILIARY_FILE_NAME,
1676+
&file_sizes,
1677+
)
1678+
.await?;
16471679

16481680
let ivf_data = IvfModel::load(&reader).await?;
16491681
let options = HNSWIndexOptions {
@@ -1960,6 +1992,125 @@ mod tests {
19601992
use lance_index::metrics::NoOpMetricsCollector;
19611993
use lance_linalg::distance::MetricType;
19621994

1995+
/// `open_index_file` skips the HEAD when the size is known and still falls
1996+
/// back to a HEAD for older indices that did not record sizes. A HEAD is
1997+
/// issued as a `get_opts` call with `head = true`, so a proxy store counts
1998+
/// those against the index file.
1999+
///
2000+
/// Regression test for <https://github.com/lance-format/lance/issues/6944>.
2001+
#[tokio::test]
2002+
async fn test_open_index_file_skips_head_when_size_known() {
2003+
use lance_index::INDEX_FILE_NAME;
2004+
use lance_io::assert_io_eq;
2005+
use lance_io::object_store::{ObjectStoreParams, ObjectStoreRegistry};
2006+
2007+
let (store, base) = ObjectStore::from_uri_and_params(
2008+
Arc::new(ObjectStoreRegistry::default()),
2009+
"memory:///",
2010+
&ObjectStoreParams::default(),
2011+
)
2012+
.await
2013+
.unwrap();
2014+
2015+
let path = base.join(INDEX_FILE_NAME);
2016+
// Larger than the block size so size discovery needs a separate HEAD.
2017+
let data = vec![7u8; 2 * store.block_size()];
2018+
store.put(&path, &data).await.unwrap();
2019+
2020+
let file_sizes = HashMap::from([(INDEX_FILE_NAME.to_string(), data.len() as u64)]);
2021+
2022+
// Size recorded in the manifest, so reading the size issues no HEAD.
2023+
let _ = store.io_stats_incremental(); // reset
2024+
let reader = open_index_file(store.as_ref(), &path, INDEX_FILE_NAME, &file_sizes)
2025+
.await
2026+
.unwrap();
2027+
assert_eq!(reader.size().await.unwrap(), data.len());
2028+
let stats = store.io_stats_incremental();
2029+
assert_io_eq!(
2030+
stats,
2031+
read_iops,
2032+
0,
2033+
"a known file size must not trigger a HEAD request"
2034+
);
2035+
2036+
// Size unknown, as in an older index, so it falls back to a HEAD.
2037+
let _ = store.io_stats_incremental(); // reset
2038+
let reader = open_index_file(store.as_ref(), &path, INDEX_FILE_NAME, &HashMap::new())
2039+
.await
2040+
.unwrap();
2041+
assert_eq!(reader.size().await.unwrap(), data.len());
2042+
let stats = store.io_stats_incremental();
2043+
assert_io_eq!(
2044+
stats,
2045+
read_iops,
2046+
1,
2047+
"an unknown file size must fall back to exactly one HEAD request"
2048+
);
2049+
}
2050+
2051+
/// `open_index_file` looks up sizes in `IndexMetadata::file_size_map()` by
2052+
/// bare file name. This pins that a freshly created HNSW index records both
2053+
/// the main and auxiliary files under those exact names with nonzero sizes,
2054+
/// which is what lets the open path skip the HEAD.
2055+
#[tokio::test]
2056+
async fn test_hnsw_index_records_file_sizes() {
2057+
use lance_index::{INDEX_AUXILIARY_FILE_NAME, INDEX_FILE_NAME};
2058+
2059+
let test_dir = TempStrDir::default();
2060+
let uri = format!("{}/ds", test_dir.as_str());
2061+
2062+
let reader = lance_datagen::gen_batch()
2063+
.col("vector", array::rand_vec::<Float32Type>(32.into()))
2064+
.into_reader_rows(RowCount::from(400), BatchCount::from(1));
2065+
let mut dataset = Dataset::write(reader, &uri, None).await.unwrap();
2066+
2067+
let params = VectorIndexParams::with_ivf_hnsw_pq_params(
2068+
MetricType::L2,
2069+
IvfBuildParams {
2070+
num_partitions: Some(8),
2071+
..Default::default()
2072+
},
2073+
HnswBuildParams {
2074+
max_level: 6,
2075+
m: 24,
2076+
ef_construction: 120,
2077+
prefetch_distance: None,
2078+
},
2079+
PQBuildParams {
2080+
num_sub_vectors: 8,
2081+
num_bits: 8,
2082+
..Default::default()
2083+
},
2084+
);
2085+
dataset
2086+
.create_index(
2087+
&["vector"],
2088+
IndexType::Vector,
2089+
Some("hnsw".to_string()),
2090+
&params,
2091+
false,
2092+
)
2093+
.await
2094+
.unwrap();
2095+
2096+
let indices = dataset.load_indices().await.unwrap();
2097+
let index = indices.iter().find(|idx| idx.name == "hnsw").unwrap();
2098+
let file_sizes = index.file_size_map();
2099+
2100+
assert!(
2101+
file_sizes.get(INDEX_FILE_NAME).copied().unwrap_or(0) > 0,
2102+
"manifest should record a nonzero {INDEX_FILE_NAME} size, got {file_sizes:?}"
2103+
);
2104+
assert!(
2105+
file_sizes
2106+
.get(INDEX_AUXILIARY_FILE_NAME)
2107+
.copied()
2108+
.unwrap_or(0)
2109+
> 0,
2110+
"manifest should record a nonzero {INDEX_AUXILIARY_FILE_NAME} size, got {file_sizes:?}"
2111+
);
2112+
}
2113+
19632114
#[tokio::test]
19642115
async fn test_initialize_vector_index_ivf_pq() {
19652116
let test_dir = TempStrDir::default();

rust/lance/src/index/vector/details.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -503,7 +503,15 @@ pub async fn infer_vector_index_details(
503503
let index_dir = dataset.indice_files_dir(index)?;
504504
let file_dir = index_dir.clone().join(uuid.as_str());
505505
let index_file = file_dir.clone().join(INDEX_FILE_NAME);
506-
let reader: Arc<dyn Reader> = dataset.object_store.open(&index_file).await?.into();
506+
let file_sizes = index.file_size_map();
507+
let reader: Arc<dyn Reader> = super::open_index_file(
508+
dataset.object_store.as_ref(),
509+
&index_file,
510+
INDEX_FILE_NAME,
511+
&file_sizes,
512+
)
513+
.await?
514+
.into();
507515

508516
let tailing_bytes = read_last_block(reader.as_ref()).await?;
509517
let (major_version, minor_version) = read_version(&tailing_bytes)?;

rust/lance/src/index/vector/ivf.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ use super::{
1313
utils::PartitionLoadLock,
1414
};
1515
use crate::dataset::index::dataset_format_version;
16+
use crate::index::DatasetIndexExt;
1617
use crate::index::DatasetIndexInternalExt;
18+
use crate::index::vector::open_index_file;
1719
use crate::index::vector::utils::{get_vector_dim, get_vector_type};
1820
use crate::{
1921
dataset::Dataset,
@@ -1842,7 +1844,15 @@ pub(crate) async fn remap_index_file(
18421844
let old_path = dataset.indices_dir().join(old_uuid).join(INDEX_FILE_NAME);
18431845
let new_path = dataset.indices_dir().join(new_uuid).join(INDEX_FILE_NAME);
18441846

1845-
let reader: Arc<dyn Reader> = object_store.open(&old_path).await?.into();
1847+
let file_sizes = dataset
1848+
.load_index(old_uuid)
1849+
.await?
1850+
.map(|index| index.file_size_map())
1851+
.unwrap_or_default();
1852+
let reader: Arc<dyn Reader> =
1853+
open_index_file(object_store, &old_path, INDEX_FILE_NAME, &file_sizes)
1854+
.await?
1855+
.into();
18461856
let mut writer = object_store.create(&new_path).await?;
18471857

18481858
let tasks = generate_remap_tasks(&index.ivf.offsets, &index.ivf.lengths)?;

0 commit comments

Comments
 (0)