Skip to content

perf(vector/index): build-time visited-set / candidate-heap reuse with thread-local arenas #632

Description

@mosuka

Round-3 perf push sub-issue (tracked under umbrella #535).

[M] Build-time visited-set / candidate-heap reuse with thread-local arenas

  • Where: laurus/src/vector/index/hnsw/writer.rs (search_layer, currently around line 1243
    — the original :842-938 line reference had drifted; see investigation comment below)
    allocates HashSet visited, two BinaryHeaps, and a Vec per call).

  • Current behaviour: every node insertion calls search_layer once
    per layer (so O(top_level) times per node); each call allocates a
    fresh HashSet<u64> + two BinaryHeaps. With rayon parallel build
    the allocator becomes a bottleneck.

  • Why it might be a bottleneck: HashSet::new() is roughly free
    but the first inserts trigger heap allocation; the heaps grow to >= ef
    capacity. With M=16, ef_c=200, 10 M nodes, this is on the order of
    hundreds of millions of allocator calls.

  • Reference precedent: hnswlib uses a thread-local "visitedList"
    pool that resets a u16 generation counter per call instead of
    clearing the set; FAISS does the same with VisitedTable.

  • Current data structure: HashSet<u64> resized per call,
    BinaryHeap<Candidate> resized per call.

  • Proposed layout (superseded — see investigation comment below for the adopted design, a
    BitVec + touched-list arena instead of this Vec<u32> generation-counter shape, which turned
    out to retain hundreds of MB per thread for the process lifetime):

    ThreadBuildArena {
        visited_gen : Vec<u32>    // sized to max_doc_id + 1
        gen_counter : u32         // bumped on each search; visited iff visited_gen[i] == gen_counter
        to_visit    : BinaryHeap<VisitorCandidate>   // capacity ef_c, .clear() per call
        found       : BinaryHeap<Candidate>          // capacity ef_c, .clear() per call
    }
    

    Reset is one increment + two clear()s — no allocations after warm-up.

  • Suggested direction: introduce ThreadBuildArena and pass it
    through search_layer and prune_neighbors. Pair with the read-side
    arena HnswSearcher already uses for the visited-set bitmap (perf(vector/hnsw): replace HashSet visited-set with bitmap #406).
    (Superseded — see investigation comment: the read side does not actually have a thread_local!
    arena, and prune_neighbors is out of scope for this PR; see task list below.)

  • Risk / scope: medium; the arena needs sizing once max_doc_id
    is known. Substantial CPU savings for large parallel builds.

Task list



ID: VI-14 — see ~/.claude/tasks/laurus/20260523_perf_round3_audit/task_list.md for the full Round-3 issue list.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions