High-performance HNSW implementation with AVX2 optimizations and parallel search.
go get github.com/BryceWayne/hnsw
go get golang.org/x/sys/cpu # Required for AVX2 detectionBuild with AVX2 support:
GOAMD64=v3 go buildimport (
"github.com/BryceWayne/hnsw"
"golang.org/x/sys/cpu"
)
func main() {
if cpu.X86.HasAVX2 {
println("Using AVX2 optimizations")
}
// Create index
index := hnsw.New(
128, // dimension
16, // M (max connections per layer)
32, // Mmax (max connections at layer 0)
100, // efConstruction
hnsw.Euclidean, // AVX2-optimized distance function
)
// Batch insert with parallel processing
vectors := make(map[int]hnsw.Vector, 1000)
for i := 0; i < 1000; i++ {
vectors[i] = generateRandomVector(128)
}
index.BatchInsert(vectors, 100) // batch size 100
// Parallel search
results := index.Search(queryVector, 10) // ~9µs per search
}package main
import (
"flag"
"fmt"
"math/rand"
"sync"
"time"
"github.com/BryceWayne/hnsw"
// Uncomment for AVX2 optimizations
// "golang.org/x/sys/cpu"
)
var (
dimension = flag.Int("d", 128, "Vector dimension")
numVectors = flag.Int("n", 1000, "Number of vectors")
connections = flag.Int("m", 16, "Max connections per layer")
maxConnections = flag.Int("mmax", 32, "Max connections at layer 0")
efConstruction = flag.Int("ef", 100, "EF construction parameter")
searchK = flag.Int("k", 10, "Number of nearest neighbors")
batchSize = flag.Int("batch", 25, "Batch size for parallel insertions")
parallel = flag.Bool("parallel", true, "Enable parallel search")
workerCount = flag.Int("workers", runtime.GOMAXPROCS(0), "Number of worker threads") // Default is runtime.NumCPU()
)
func main() {
flag.Parse()
rand.Seed(time.Now().UnixNano())
// Initialize index
index := hnsw.New(
*dimension,
*connections,
*maxConnections,
*efConstruction,
hnsw.Euclidean,
)
// Generate and insert vectors in batches
vectors := make([]hnsw.Vector, *numVectors)
for i := range vectors {
vectors[i] = generateRandomVector(*dimension)
}
numBatches := (*numVectors + *batchSize - 1) / *batchSize
for b := 0; b < numBatches; b++ {
start := b * *batchSize
end := min(start+*batchSize, *numVectors)
var wg sync.WaitGroup
for i := start; i < end; i++ {
wg.Add(1)
go func(id int, vec hnsw.Vector) {
defer wg.Done()
index.Insert(id, vec)
}(i, vectors[i])
}
wg.Wait()
}
// Search
queryVector := generateRandomVector(*dimension)
// Default parallel search
results := index.Search(query, 10)
// Custom config for sequential search
config := hnsw.SearchConfig{
UseParallel: *useParallel,
WorkerCount: *workerCount,
}
results := index.SearchWithConfig(query, 10, config)
fmt.Printf("Found neighbors: %v\n", results)
}Run with:
go run main.go -d 256 -n 10000 -m 32 -ef 100 -k 20 -batch 25Run with custom parameters:
go run main.go \
-d 256 \ # dimension
-n 10000 \ # number of vectors
-m 32 \ # max connections
-ef 100 \ # ef construction
-k 20 \ # k nearest neighbors
-batch 25 \ # batch size
-parallel=true \ # use parallel search
-workers 16 # worker threadsFlag descriptions:
-d int
Vector dimension (default 128)
-n int
Number of vectors (default 1000)
-m int
Max connections per layer (default 16)
-mmax int
Max connections at layer 0 (default 32)
-ef int
EF construction parameter (default 100)
-k int
Number of nearest neighbors (default 10)
-batch int
Batch size for parallel insertions (default 25)
-parallel
Use parallel search (default true)
-workers int
Number of worker threads (default: CPU cores)
Performance impact:
- Parallel search (-parallel=true): ~20x speedup
- Worker count (-workers): Scale with available CPU cores
- Batch size (-batch): Memory vs speed tradeoff
type Vector []float64
type DistanceFunc func(Vector, Vector) float64func New(dim, m, mmax, efConstruction int, distanceFunc DistanceFunc) *HNSWCreates new HNSW index:
dim: Vector dimensionm: Max connections per layermmax: Max connections at layer 0efConstruction: Search quality during construction (recommend 100-200)distanceFunc: Distance metric function (Euclidean or Cosine provided)
func (h *HNSW) Insert(id int, vec Vector)Inserts vector with given ID. Thread-safe.
func (h *HNSW) Search(vec Vector, k int) []intReturns IDs of k nearest neighbors. Thread-safe.
func (h *HNSW) Delete(id int)Removes vector from index. Thread-safe.
func (h *HNSW) Save(filename string) error
func Load(filename string, distanceFunc DistanceFunc) (*HNSW, error)Persistence functions.
Euclidean: Standard Euclidean distanceCosine: Cosine similarity as distance
Sample benchmark (256d vectors, 10k points):
{
"dimension": 256,
"num_vectors": 10000,
"connections": 32,
"max_connections": 32,
"ef_construction": 100,
"distance_metric": "euclidean",
"build_time": 556174141967,
"search_time": 376427279,
"memory_usage": 33609840
}hnsw/
├── examples/ # Example usage
├── distance.go # Distance metrics
├── hnsw.go # Main HNSW implementation
├── node.go # Node implementation
├── serialize.go # Serialization logic
└── types.go # Core data types
HNSW (Hierarchical Navigable Small World) is an algorithm for approximate nearest neighbor search that creates a layered graph structure. Each layer is a "small world" graph, with the number of connections between nodes decreasing as you go up the layers.
graph TD
subgraph L3 [Layer 3]
Entry[Entry Point]
end
subgraph L2 [Layer 2]
N2_1(( )) --- N2_2(( ))
end
subgraph L1 [Layer 1]
N1_1(( )) --- N1_2(( )) --- N1_3(( ))
end
subgraph L0 [Layer 0 - Ground]
N0_1(( )) --- N0_2(( )) --- N0_3(( )) --- N0_4(( ))
end
Entry -.-> N2_1
N2_2 -.-> N1_2
N1_3 -.-> N0_3
The hierarchical structure consists of layers:
- L0 (ground layer): Most connections, finest-grained search
- L1-L2: Balanced layers for navigation
- L3+: Skip-list layers for fast traversal
Key properties:
- Entry point at top layer
- Increasing connectivity at lower layers
- Skip-list organization for efficient search
- Layer count scales logarithmically with data size
flowchart TD
Start([Start]) --> Entry[Enter at Layer 3]
Entry --> TraverseL3[Traverse L3]
TraverseL3 --> DescendL2{Descend}
DescendL2 --> TraverseL2[Traverse L2]
TraverseL2 --> DescendL1{Descend}
DescendL1 --> TraverseL1[Traverse L1]
TraverseL1 --> DescendL0{Descend}
DescendL0 --> SearchL0[Search Layer 0]
SearchL0 --> Result([Found Nearest Neighbors])
- Begin at entry point in highest layer
- Explore current layer to find closest node
- Descend to next layer and repeat
- Final search in bottom layer (Layer 0)
flowchart TD
Start([New Node]) --> MaxLevel{Select Max Level}
MaxLevel --> Find[Find Neighbors at Level]
Find --> Connect[Create Connections]
Connect --> Prune{Connections > M?}
Prune -- Yes --> Remove[Prune Weakest]
Prune -- No --> NextLevel
Remove --> NextLevel
NextLevel{More Levels?} -- Yes --> Descend[Descend Level]
Descend --> Find
NextLevel -- No --> Done([Inserted])
- Randomly select maximum level for new node
- Find nearest neighbors at each level
- Create bidirectional connections
- Maintain connection limits through pruning
flowchart TD
Start([Delete Node]) --> Locate[Locate Node]
Locate --> Neighbors[Identify Neighbors]
Neighbors --> Remove[Remove Incoming Edges]
Remove --> Reconnect[Reconnect Neighbors]
Reconnect --> CheckM{Connections < M_min}
CheckM -- Yes --> Repair[Repair Connectivity]
CheckM -- No --> Done
Repair --> Done([Deleted])
The deletion process involves:
- Locate target node and connections
- Remove incoming connections from neighbors
- Reconnect affected neighbors to maintain graph connectivity
- Update layer structures as needed
- Thread-safe concurrent deletions
Key aspects:
- Maintains graph connectivity after node removal
- Updates neighbor connections optimally
- Handles edge cases (entry point deletion)
- M/Mmax limits preserved
graph TD
Top((Entry)) --> Mid1(( ))
Top --> Mid2(( ))
Mid1 --> Bot1(( ))
Mid1 --> Bot2(( ))
Mid2 --> Bot3(( ))
Mid2 --> Bot4(( ))
Bot1 --- Bot2
Bot2 --- Bot3
Bot3 --- Bot4
The network maintains efficiency through:
- Balance of short/long-range connections
- Limited connections per node (M/Mmax)
- Hierarchical navigation structure
flowchart LR
EF[EF Parameter] --> Low[Low Value]
EF --> High[High Value]
Low --> Fast[Faster Search]
Low --> LowAcc[Lower Accuracy]
High --> Slow[Slower Search]
High --> HighAcc[Higher Accuracy]
EF (Exploration Factor) controls:
- Lower EF: Faster search, less accurate
- Higher EF: Slower search, more accurate
sequenceDiagram
participant Main
participant W1 as Worker 1
participant W2 as Worker 2
participant Index
Main->>W1: Batch 1 (25 items)
Main->>W2: Batch 2 (25 items)
par Parallel Insert
W1->>Index: Insert Nodes
W2->>Index: Insert Nodes
end
W1-->>Main: Done
W2-->>Main: Done
The index supports efficient batch operations:
- Data is split into configurable batch sizes (default 25 vectors)
- Worker threads process batches in parallel
- Each worker handles node insertion and connection formation
- Concurrent operations maintain thread safety
- Progress tracking for large batches
Recommended batch sizes:
- Small datasets (<10K): 25-50 vectors
- Large datasets: 100-200 vectors
- Memory constrained: Reduce to 10-25
-
M/Mmax (Connection Limits):
M: 12-16 for high-dimensional dataMmax: Usually 2*M for ground layer
-
EF (Search Quality):
- Lower values (64): Faster, less accurate
- Higher values (128+): Slower, more accurate
- Construction: 100-200 recommended
-
Distance Metrics:
- Cosine: Best for text/embedding vectors
- Euclidean: Better for coordinate-based data
Run the test suite:
go test -v ./...Run benchmarks:
go test -bench=. ./...Current test results:
go test -v ./...
? github.com/BryceWayne/hnsw/examples [no test files]
=== RUN TestBatchOperations
--- PASS: TestBatchOperations (0.01s)
=== RUN TestEuclidean
--- PASS: TestEuclidean (0.00s)
=== RUN TestCosine
--- PASS: TestCosine (0.00s)
=== RUN TestNew
--- PASS: TestNew (0.00s)
=== RUN TestInsertAndSearch
--- PASS: TestInsertAndSearch (0.00s)
=== RUN TestSearch
=== RUN TestSearch/Find_nearest_to_origin
=== RUN TestSearch/Find_two_nearest
--- PASS: TestSearch (0.00s)
--- PASS: TestSearch/Find_nearest_to_origin (0.00s)
--- PASS: TestSearch/Find_two_nearest (0.00s)
=== RUN TestParallelSearch
--- PASS: TestParallelSearch (0.00s)
=== RUN TestConcurrentSearches
--- PASS: TestConcurrentSearches (0.01s)
=== RUN TestDelete
--- PASS: TestDelete (0.00s)
=== RUN TestSaveLoad
--- PASS: TestSaveLoad (0.00s)
=== RUN TestConcurrentInserts
--- PASS: TestConcurrentInserts (0.00s)
PASS
ok github.com/BryceWayne/hnsw 0.022s
Benchmark results (Intel i9-12900K):
Operation Time/op
Sequential Insert ~269µs
Sequential Search ~146µs
Parallel Search ~ 29µs
Batch Insert ~262µs
Batch Search ~170ns
Dimension Scaling (ns/op):
Size Dim Time
1K 32 32,611
1K 128 29,973
1K 512 18,247
10K 32 20,716
10K 128 18,288
10K 512 13,414
100K 32 24,083
100K 128 16,302
100K 512 13,650
Key Features:
- 5x speedup with parallel search
- Sub-microsecond batch search
- Better performance with higher dimensions
- Optimal for large datasets (100K+ vectors)
- AVX2 support for SIMD optimizations
- Multiple CPU cores for parallel search
- Recommended: 16GB+ RAM for 100K+ vectors
-
Batch Size:
- Small datasets (<10K): 25-50
- Large datasets: 100-200
-
Worker Count:
- Default: Number of CPU cores
- High load: 2x CPU cores
-
Memory vs Speed:
- Lower M (8-12): Less memory, slower search
- Higher M (16-32): Faster search, more memory
-
Index Construction:
- Use batch insertions (25-50 vectors per batch)
- Pre-generate vectors before insertion
- Lower
efConstruction(100) for faster builds
-
Search Optimization:
- Adjust EF based on accuracy needs
- Use appropriate distance metric
-
Memory Management:
- Monitor node count vs. available RAM
- Consider saving/loading for large datasets
MIT License
Copyright (c) 2024 Bryce Wayne