IndexFastScan::reset leaves stale ntotal2, causing SIGSEGV on search
Status
- Affected API:
train,add,reset,search - Affected type: public CPU
IndexPQFastScan - Configuration: 4-bit PQ,
bbs=32
Summary
After training and adding one vector, calling the public IndexPQFastScan::reset() API leaves the internal rounded scan count ntotal2 unchanged.
The index reports:
ntotal=0
ntotal2=32
codes.size=0A subsequent search uses the stale value ntotal2=32 and passes the empty code buffer to the SIMD scan kernel. The kernel dereferences a null code pointer and the process terminates with SIGSEGV.
Resetting a trained empty index is safe, and searching before reset is safe. The crash is specific to stale post-reset state.
Reproduction
Run:
python3 repro.py --trials 10#!/usr/bin/env python3
"""Reproduce stale IndexFastScan::ntotal2 after reset()."""
import argparse
import datetime as dt
import json
from pathlib import Path
import subprocess
import sys
from oracle import (
check_baseline,
check_bug,
check_control,
expected_baseline,
expected_bug_contract,
expected_control,
)
HERE = Path(__file__).resolve().parent
def find_root():
for parent in HERE.parents:
if (parent / "VDBMS/faiss/faiss/IndexFastScan.cpp").is_file():
return parent
raise RuntimeError("cannot locate checked-out VDBMSFuzz root")
def now():
return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat()
def run_process(binary, mode, execution_dir):
execution_dir.mkdir(parents=True, exist_ok=True)
try:
result = subprocess.run(
[str(binary), mode],
cwd=str(execution_dir),
capture_output=True,
text=True,
timeout=30,
check=False,
)
return {
"mode": mode,
"returncode": result.returncode,
"stdout": result.stdout,
"stderr": result.stderr,
"timed_out": False,
"execution_dir": str(execution_dir),
}
except subprocess.TimeoutExpired as exc:
return {
"mode": mode,
"returncode": None,
"stdout": exc.stdout or "",
"stderr": exc.stderr or "",
"timed_out": True,
"execution_dir": str(execution_dir),
}
def write_json(path, value):
path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--trials", type=int, default=10)
args = parser.parse_args()
if args.trials < 10:
print("--trials must be at least 10", file=sys.stderr)
return 2
root = find_root()
build = root / "VDBMS/bug-finder-agent/work/faiss/build-make"
static_lib = build / "faiss/libfaiss.a"
binary = HERE / "runtime/repro_helper"
log_dir = HERE / "logs"
runtime_dir = HERE / "runtime"
log_dir.mkdir(parents=True, exist_ok=True)
runtime_dir.mkdir(parents=True, exist_ok=True)
if not static_lib.is_file():
print("missing audited FAISS static library", file=sys.stderr)
return 2
compile_command = [
"g++",
"-std=c++17",
"-O2",
"-I",
str(root / "VDBMS/faiss"),
"-I",
str(build),
"-o",
str(binary),
str(HERE / "repro_helper.cpp"),
str(static_lib),
"-fopenmp",
"/lib/x86_64-linux-gnu/libblas.so.3",
"/lib/x86_64-linux-gnu/liblapack.so.3",
]
compile_result = subprocess.run(
compile_command, cwd=str(root), capture_output=True, text=True, check=False
)
write_json(
runtime_dir / "compile.json",
{
"command": compile_command,
"returncode": compile_result.returncode,
"stdout": compile_result.stdout,
"stderr": compile_result.stderr,
},
)
if compile_result.returncode != 0:
return 2
started = now()
control = run_process(binary, "control", runtime_dir / "control-run")
baseline = run_process(binary, "baseline", runtime_dir / "baseline-run")
# Three rotating fresh execution directories document the directory-level
# stability schedule. The target API itself is in-memory and has no data
# directory; each process constructs a new trained index from fixed bytes.
trial_results = []
for number in range(args.trials):
execution_dir = runtime_dir / f"fresh-run-{(number % 3) + 1}-{number + 1}"
execution_dir.mkdir(parents=True, exist_ok=True)
result = run_process(binary, "bug", execution_dir)
result["oracle_match"] = check_bug(result)
result["fresh_process"] = True
result["fresh_in_memory_index"] = True
result["fresh_execution_directory"] = True
trial_results.append(result)
control_pass = check_control(control)
baseline_pass = check_baseline(baseline)
bug_pass = all(item["oracle_match"] for item in trial_results)
finished = now()
raw = {
"started_at": started,
"finished_at": finished,
"control": control,
"baseline": baseline,
"trials": trial_results,
}
normalized = {
"control_pass": control_pass,
"baseline_pass": baseline_pass,
"trial_count": len(trial_results),
"trial_pass_count": sum(item["oracle_match"] for item in trial_results),
"all_trials_match_bug_oracle": bug_pass,
"expected_control": expected_control(),
"expected_baseline": expected_baseline(),
"expected_bug_contract": expected_bug_contract(),
"fresh_process_per_trial": True,
"fresh_in_memory_index_per_trial": True,
"fresh_execution_directories": 3,
"target_data_directory": "not applicable; public index is in-memory",
}
schedule = {
"kind": "deterministic_crash_stability",
"required_independent_reproductions": 3,
"required_fresh_process_trials": 10,
"actual_trials": len(trial_results),
"control_runs": 1,
"baseline_runs": 1,
"fresh_execution_directories": 3,
"target_data_directories": "not applicable; no persistence API is exercised",
"fixed_seed": "deterministic training bytes; no RNG",
"operation_sequence": "train -> add one vector -> reset -> search",
"started_at": started,
"finished_at": finished,
}
write_json(HERE / "raw-responses.json", raw)
write_json(HERE / "normalized-results.json", normalized)
write_json(HERE / "trial-results.json", trial_results)
write_json(HERE / "schedule.json", schedule)
(log_dir / "stability.log").write_text(
"started_at="
+ started
+ "\ncompile_returncode="
+ str(compile_result.returncode)
+ "\ncontrol="
+ json.dumps(control, sort_keys=True)
+ "\nbaseline="
+ json.dumps(baseline, sort_keys=True)
+ "\n"
+ "\n".join(
"trial_%d=" % (number + 1) + json.dumps(item, sort_keys=True)
for number, item in enumerate(trial_results)
)
+ "\nfinished_at="
+ finished
+ "\n"
)
if not control_pass or not baseline_pass or not bug_pass:
print("oracle failed", file=sys.stderr)
return 3
print(json.dumps(normalized, sort_keys=True))
return 10
if __name__ == "__main__":
raise SystemExit(main())
The minimized sequence is:
train -> add one vector -> reset -> searchThe test uses only public in-memory APIs. No metadata, payload, filter, predicate, or wrapper behavior is involved.
Expected post-reset state:
ntotal=0
ntotal2=0
codes.size=0
search result label=-1Observed post-reset state:
ntotal=0
ntotal2=32
codes.size=0The search then terminates with:
SIGSEGVThe control case succeeds:
mode=control ntotal=0 ntotal2=0 codes=0 label=-1The baseline before reset also succeeds:
mode=baseline ntotal=1 ntotal2=32 label=0All ten fresh-process trials reproduce the crash.
Root Cause
IndexFastScan::reset() currently performs:
codes.resize(0);
ntotal = 0;but does not reset ntotal2.
Adding one vector rounds the packed scan count up to 32 because bbs=32. After reset, the packed code buffer is empty, but ntotal2 remains 32.
The search implementation still passes both values to the SIMD accumulation loop:
Current IndexFastScan::reset implementation
[Current FastScan search path](https://github.com/facebookresearch/faiss/blob/main/faiss/IndexFastScan.cpp#L503-L510)
The captured GDB trace reaches kernels_simd256.h, where the kernel loads from codes=0x0 while the block iterator still processes 32 entries.
Why This Is a Bug
reset() is a public lifecycle operation that removes all vectors from the index. A successful reset must leave the index usable as a trained empty index.
Searching an empty index should return the empty-result sentinel, such as label -1, rather than dereferencing an empty buffer and crashing.
The stale ntotal2 value violates the internal invariant between the packed-code buffer and the number of scan entries.
Impact
Applications that reuse an IndexPQFastScan object between batches can experience a deterministic process crash on the first search after resetting a populated index.
This causes availability loss. No result is returned before the crash.
Known workaround:
Destroy and reconstruct the index instead of calling reset() after adding vectors.Suggested Resolution
After resetting the packed code storage, ntotal2 should also be reset to zero.
Alternatively, the search path must explicitly avoid scanning when ntotal == 0. In either case, searching after reset must behave like searching a fresh trained empty index.
Source: facebookresearch/faiss