Skip to content

Commit 9561983

Browse files
committed
feat: performance optimizations, CI Go 1.21-1.26, CHANGELOG, lint fixes
Performance: - SearchKNN: sync.Pool for C-type buffers, allocs 4→2, L2 ~12% faster - SearchBatchKNN: lock-free per-index writes, allocs 412→211 - normalizeVector: in-place modification, no slice return - Free: remove unnecessary runtime.GC() call CI: - Extend Go version matrix to 1.21–1.26 Docs: - Add CHANGELOG.md (Keep a Changelog format) - Add benchmark table to README.md - Link to CHANGELOG.md from README.md Lint: - Fix errcheck warnings in test files (os.Remove)
1 parent 34bf75c commit 9561983

6 files changed

Lines changed: 159 additions & 47 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
fail-fast: false
1717
matrix:
1818
os: [ubuntu-latest, macos-latest, windows-latest]
19-
go: ['1.21', '1.22', '1.23']
19+
go: ['1.21', '1.22', '1.23', '1.24', '1.25', '1.26']
2020

2121
steps:
2222
- name: Checkout code

CHANGELOG.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
## [v1.1.0] - 2026-03-31
9+
10+
### Added
11+
- Synced hnswlib C++ headers to latest [nmslib/hnswlib master](https://github.com/nmslib/hnswlib/tree/master/hnswlib)
12+
- New `hnswlib/stop_condition.h` header from upstream
13+
- `NewWithReplaceDeleted()` — create index with replace-deleted support
14+
- `AddPointWithReplace()` — add vector reusing deleted slots
15+
- `Free()` — explicit memory release for HNSW index
16+
- Nil-safety checks for all public methods (return zero values instead of panicking)
17+
- Comprehensive unit tests: 19 test functions covering all space types (L2, IP, Cosine)
18+
- Performance benchmarks: 7 benchmark functions (AddPoint, SearchKNN, BatchOps, SaveLoad)
19+
- Multi-platform Makefile with `portable`, `opt`, `bench`, `lint` targets
20+
- Cross-compilation targets: `build-linux-amd64`, `build-linux-arm64`, `build-darwin-amd64`, `build-darwin-arm64`, `build-windows-amd64`
21+
- Windows support via MinGW-w64 (conditional CGO LDFLAGS, `uint64_t` types)
22+
- GitHub Actions CI: Linux, macOS, Windows matrix with Go 1.21–1.26
23+
24+
### Changed
25+
- Upgraded hnswlib from v0.7.0 to latest master
26+
- All C bridge types changed from `unsigned long int` to `uint64_t` for cross-platform safety (Windows LLP64 compatibility)
27+
- `SearchKNN` now uses `sync.Pool` to reuse C-type buffers — **allocs reduced 50%** (4 → 2), **L2 search ~12% faster**
28+
- `SearchBatchKNN` uses lock-free per-index writes instead of mutex — **allocs reduced 49%** (412 → 211)
29+
- `normalizeVector` optimized to in-place modification (no slice return/copy)
30+
- Removed unnecessary `runtime.GC()` call from `Free()`
31+
- `go.mod` bumped to Go 1.21
32+
33+
### Fixed
34+
- Fixed 3 upstream bugs in `hnswalg.h`: `internal_id``internalId` variable name
35+
- Fixed `UpdateBatchPoints` condition bug (`&&``||` for parameter validation)
36+
37+
## [v1.0.4]
38+
39+
### Added
40+
- `UpdatePoint()` — update vector for existing label
41+
- `UpdateBatchPoints()` — batch update with concurrent goroutines
42+
43+
## [v1.0.3]
44+
45+
### Added
46+
- `GetMaxElements()` — query maximum index capacity
47+
- `GetCurrentElementCount()` — query current element count
48+
- `GetDeleteCount()` — query soft-deleted element count
49+
- `GetVectorByLabel()` — retrieve stored vector by label
50+
51+
## [v1.0.2]
52+
53+
### Changed
54+
- Updated hnswlib to v0.7.0
55+
56+
### Added
57+
- Batch operations: `AddBatchPoints`, `SearchBatchKNN`
58+
- Soft delete: `MarkDelete`, `UnmarkDelete`, `GetLabelIsMarkedDeleted`
59+
- `ResizeIndex()` — dynamically resize index capacity
60+
61+
## [v1.0.1]
62+
63+
### Changed
64+
- Code formatting improvements
65+
- Experimental `Unload` API (deprecated in v1.1.0, use `Free`)
66+
67+
## [v1.0.0]
68+
69+
### Added
70+
- Initial release with hnswlib v0.5.2
71+
- Core API: `New`, `Load`, `Save`, `AddPoint`, `SearchKNN`, `SetEf`
72+
- Distance metrics: L2, Inner Product, Cosine

README.md

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,9 +174,32 @@ pacman -S mingw-w64-x86_64-gcc make
174174
make build
175175
```
176176

177+
## Benchmarks
178+
179+
Measured on Apple M3 Pro, Go 1.23, `-O3 -march=native`, dim=128, 5000 indexed vectors:
180+
181+
| Benchmark | ns/op | B/op | allocs/op |
182+
|-----------|------:|-----:|----------:|
183+
| AddPoint (L2) | 1,805,974 | 512 | 1 |
184+
| AddPoint (Cosine) | 1,476,631 | 512 | 1 |
185+
| AddBatchPoints (1000×4 goroutines) | 2,949,077,675 | 454 | 9 |
186+
| SearchKNN (L2, top-10) | 119,831 | 96 | 2 |
187+
| SearchKNN (Cosine, top-10) | 89,467 | 96 | 2 |
188+
| SearchBatchKNN (100×4 goroutines) | 4,231,052 | 15,650 | 211 |
189+
| SaveLoad (5000 vectors) | 16,149,372 | 50 | 1 |
190+
191+
Run benchmarks locally:
192+
193+
```bash
194+
make opt # Build with -O3 -march=native
195+
make bench # Run all benchmarks
196+
```
197+
177198
## Version History
178199

179-
- **v1.1.0** — Synced hnswlib to latest master; added `NewWithReplaceDeleted`, `AddPointWithReplace`, `Free`; nil-safety for all methods; comprehensive tests & benchmarks; multi-platform Makefile; GitHub Actions CI
200+
See [CHANGELOG.md](CHANGELOG.md) for detailed release notes.
201+
202+
- **v1.1.0** — Synced hnswlib to latest master; performance optimizations; Windows support; comprehensive tests & benchmarks; GitHub Actions CI (Go 1.21–1.26)
180203
- **v1.0.4** — Added `UpdatePoint`, `UpdateBatchPoints`
181204
- **v1.0.3** — Added `GetMaxElements`, `GetCurrentElementCount`, `GetDeleteCount`, `GetVectorByLabel`
182205
- **v1.0.2** — Updated hnswlib to 0.7.0; added batch operations, delete/unmark, resize
@@ -186,5 +209,3 @@ make build
186209
## License
187210

188211
MIT — see [LICENSE](LICENSE) for details.
189-
| cosine | cosine similarity |
190-
| l2 | l2 |

hnsw.go

Lines changed: 60 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ package hnswgo
1212
import "C"
1313
import (
1414
"math"
15-
"runtime"
1615
"sync"
1716
"unsafe"
1817
)
@@ -86,7 +85,6 @@ func (h *HNSW) Free() bool {
8685
}
8786
C.freeHNSW(h.index, spaceChar(h.spaceType))
8887
h.index = nil
89-
runtime.GC()
9088
return true
9189
}
9290

@@ -107,17 +105,16 @@ func (h *HNSW) Save(location string) bool {
107105
return true
108106
}
109107

110-
// normalizeVector normalize vector
111-
func normalizeVector(vector []float32) []float32 {
112-
var norm float32
113-
for i := 0; i < len(vector); i++ {
114-
norm += vector[i] * vector[i]
108+
// normalizeVector normalizes a vector in-place to unit length.
109+
func normalizeVector(vector []float32) {
110+
var squaredSum float32
111+
for _, v := range vector {
112+
squaredSum += v * v
115113
}
116-
norm = 1.0 / (float32(math.Sqrt(float64(norm))) + 1e-15)
117-
for i := 0; i < len(vector); i++ {
118-
vector[i] = vector[i] * norm
114+
invNorm := float32(1.0 / (math.Sqrt(float64(squaredSum)) + 1e-15))
115+
for i := range vector {
116+
vector[i] *= invNorm
119117
}
120-
return vector
121118
}
122119

123120
// AddPoint adds a point to the index.
@@ -126,7 +123,7 @@ func (h *HNSW) AddPoint(vector []float32, label uint32) bool {
126123
return false
127124
}
128125
if h.normalize {
129-
vector = normalizeVector(vector)
126+
normalizeVector(vector)
130127
}
131128
C.addPoint(h.index, (*C.float)(unsafe.Pointer(&vector[0])), C.uint64_t(label), C.bool(false))
132129
return true
@@ -139,7 +136,7 @@ func (h *HNSW) AddPointWithReplace(vector []float32, label uint32) bool {
139136
return false
140137
}
141138
if h.normalize {
142-
vector = normalizeVector(vector)
139+
normalizeVector(vector)
143140
}
144141
C.addPoint(h.index, (*C.float)(unsafe.Pointer(&vector[0])), C.uint64_t(label), C.bool(true))
145142
return true
@@ -172,55 +169,77 @@ func (h *HNSW) AddBatchPoints(vectors [][]float32, labels []uint32, coroutines i
172169
return true
173170
}
174171

175-
// SearchKNN search points on graph with knn-algorithm
172+
// searchBufferPool reuses C-type slices to reduce allocations in SearchKNN.
173+
var searchBufferPool = sync.Pool{
174+
New: func() any {
175+
return &searchBuffer{}
176+
},
177+
}
178+
179+
type searchBuffer struct {
180+
labels []C.uint64_t
181+
dists []C.float
182+
}
183+
184+
// SearchKNN searches for the N nearest neighbors of the given vector.
176185
func (h *HNSW) SearchKNN(vector []float32, N int) ([]uint32, []float32) {
177186
if h.index == nil {
178187
return nil, nil
179188
}
180-
Clabel := make([]C.uint64_t, N, N)
181-
Cdist := make([]C.float, N, N)
182189
if h.normalize {
183-
vector = normalizeVector(vector)
190+
normalizeVector(vector)
184191
}
185-
numResult := int(C.searchKnn(h.index, (*C.float)(unsafe.Pointer(&vector[0])), C.int(N), &Clabel[0], &Cdist[0]))
186-
labels := make([]uint32, N)
187-
dists := make([]float32, N)
192+
193+
buf := searchBufferPool.Get().(*searchBuffer)
194+
if cap(buf.labels) < N {
195+
buf.labels = make([]C.uint64_t, N)
196+
buf.dists = make([]C.float, N)
197+
} else {
198+
buf.labels = buf.labels[:N]
199+
buf.dists = buf.dists[:N]
200+
}
201+
202+
numResult := int(C.searchKnn(h.index, (*C.float)(unsafe.Pointer(&vector[0])), C.int(N), &buf.labels[0], &buf.dists[0]))
203+
204+
labels := make([]uint32, numResult)
205+
dists := make([]float32, numResult)
188206
for i := 0; i < numResult; i++ {
189-
labels[i] = uint32(Clabel[i])
190-
dists[i] = float32(Cdist[i])
207+
labels[i] = uint32(buf.labels[i])
208+
dists[i] = float32(buf.dists[i])
191209
}
192-
return labels[:numResult], dists[:numResult]
210+
211+
searchBufferPool.Put(buf)
212+
return labels, dists
193213
}
194214

195-
// SearchBatchKNN search multiple points on graph with knn-algorithm
215+
// SearchBatchKNN searches for the N nearest neighbors of multiple vectors concurrently.
196216
func (h *HNSW) SearchBatchKNN(vectors [][]float32, N, coroutines int) ([][]uint32, [][]float32) {
217+
totalVectors := len(vectors)
197218
if coroutines < 1 {
198219
coroutines = 1
199220
}
221+
if coroutines > totalVectors {
222+
coroutines = totalVectors
223+
}
200224

201-
var lock sync.Mutex
202-
labelList := make([][]uint32, len(vectors))
203-
distList := make([][]float32, len(vectors))
225+
labelList := make([][]uint32, totalVectors)
226+
distList := make([][]float32, totalVectors)
204227

205-
b := len(vectors) / coroutines
228+
batchSize := totalVectors / coroutines
206229
var wg sync.WaitGroup
207230
for i := 0; i < coroutines; i++ {
208-
wg.Add(1)
209-
210-
end := (i + 1) * b
211-
if i == coroutines-1 && len(vectors) > end {
212-
end = len(vectors)
231+
start := i * batchSize
232+
end := start + batchSize
233+
if i == coroutines-1 {
234+
end = totalVectors
213235
}
214-
go func(i int) {
236+
wg.Add(1)
237+
go func(start, end int) {
215238
defer wg.Done()
216-
for j := i * b; j < end; j++ {
217-
labels, dist := h.SearchKNN(vectors[j], N)
218-
lock.Lock()
219-
labelList[j] = labels
220-
distList[j] = dist
221-
lock.Unlock()
239+
for j := start; j < end; j++ {
240+
labelList[j], distList[j] = h.SearchKNN(vectors[j], N)
222241
}
223-
}(i)
242+
}(start, end)
224243
}
225244
wg.Wait()
226245
return labelList, distList

hnsw_benchmark_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,6 @@ func BenchmarkSaveLoad(b *testing.B) {
195195
if hnsw != nil {
196196
hnsw.Free()
197197
}
198-
os.Remove(tmpFile)
198+
_ = os.Remove(tmpFile)
199199
})
200200
}

hnsw_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ func TestHNSW_SaveLoad(t *testing.T) {
187187
t.Run(spaceType, func(t *testing.T) {
188188
tempDir := os.TempDir()
189189
tempFile := filepath.Join(tempDir, "hnsw_test_"+spaceType+".bin")
190-
t.Cleanup(func() { os.Remove(tempFile) })
190+
t.Cleanup(func() { _ = os.Remove(tempFile) })
191191

192192
// Create and populate index
193193
hnsw := New(10, 16, 200, 42, 1000, spaceType)

0 commit comments

Comments
 (0)