#5591·faiss

IndexFastScan::reset leaves stale ntotal2, causing SIGSEGV on search

Author: leemeiiCreated Sep 7, 2026Updated Sep 7, 2026

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=0

A 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:

bash
python3 repro.py --trials 10
python
#!/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 -> search

The 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=-1

Observed post-reset state:

ntotal=0
ntotal2=32
codes.size=0

The search then terminates with:

SIGSEGV

The control case succeeds:

mode=control ntotal=0 ntotal2=0 codes=0 label=-1

The baseline before reset also succeeds:

mode=baseline ntotal=1 ntotal2=32 label=0

All ten fresh-process trials reproduce the crash.

Root Cause

IndexFastScan::reset() currently performs:

cpp
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.