perf(vector): replace vek with in-tree SIMD distance kernels - #9817
Draft
matthewmcneely wants to merge 1 commit into
Draft
perf(vector): replace vek with in-tree SIMD distance kernels#9817matthewmcneely wants to merge 1 commit into
matthewmcneely wants to merge 1 commit into
Conversation
Drop github.com/viterin/vek in favour of distance kernels maintained here, for the three metrics HNSW actually uses. vek only ships SIMD assembly for amd64, gated on `HasAVX2 && HasFMA && GOOS != "darwin"`. Everything else falls through to scalar Go, which means linux/arm64 -- an officially supported platform -- and every macOS dev box ran vector search with no acceleration at all. Upstream has ruled out changing this: viterin/vek#12 was closed with "there are no plans to add SIMD acceleration", the last release was 2025-08-14, and vek's codegen pipeline targets amd64 by construction. Kernels live in two build-tagged files behind one contract, documented in kernels.go. kernels_simd.go uses the Go 1.27 simd package and needs GOEXPERIMENT=simd at build time; kernels_generic.go is an unrolled scalar fallback so a plain `go build ./...` keeps working without the experiment. The vector width is read at run time, so the same code covers 128-bit Neon and 256/512-bit AVX. Measured on darwin/arm64 at 768 float32 dimensions, ns/op: vek fallback simd dot 575 157 108 euclidean 645 166 111 cosine 646 339 129 Two details in the kernels carry their own weight. Reslicing b to len(a) eliminates the bounds checks on b and is worth ~11%; it is not a length guard, since a short subslice of a longer array reslices back within capacity, so applyDistanceFunction remains the only enforcement point. And the dot kernels hoist their loads into locals instead of indexing inline, because under go1.27.0 on arm64 the inline form generates a 1.6x slower loop (253ns vs 158ns) while the hoisted form matches go1.26.5. Results are no longer bit-identical to the previous implementation: multiple accumulators reassociate the partial sums, and the SIMD path adds fused multiply-add. Relative error against a float64 reference stays within ~4e-7, well below the resolution at which ranking changes. Tests compare with a tolerance and cover the tail cases, degenerate inputs, and allocation behaviour, which matters because the horizontal reduction has to stay off the heap in the hottest loop in vector search. euclideanDistanceSq is renamed to euclideanDistance: it always returned the square-rooted value, and distance_threshold compares against it in the metric domain. Behaviour is unchanged. Also removes the chewxy/math32 and viterin/partial indirect dependencies. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
Author
|
@shiva-istari Took a pass at converting our vector distance calcs with the new SIMD features in go 1.27. Dropping viterin/vek in favor of our own distance functions. If GOEXPERIMENT=simd is set at build time, and Dgraph's running on amd64 we get SIMD processing! Additionally, the fallback scalar algos for non-simd are faster thanks to Claude. Would be good to run this against your larger vector benchmarks for comparison. |
matthewmcneely
marked this pull request as draft
August 25, 2026 17:59
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
This PR drops the
github.com/viterin/vekdependency and replaces it with distancekernels maintained in-tree, covering the three metrics the HNSW index actually uses: dot
product, cosine similarity, and euclidean distance. The vectorized path uses Go 1.27's new
experimental
simdpackage, with a pure-Go fallback so nothing depends on the experimentbeing enabled.
Why
vek only ships SIMD assembly for amd64, and it gates that on:
Everything outside that gate falls through to scalar Go loops. That includes
linux/arm64, which we officially support (Graviton, Ampere), plus every macOS dev box.
vek.Info()confirms it at run time, reportingAcceleration:falseon arm64. So vectorsearch on ARM has been doing
res += x[i]*y[i]one float at a time.Upstream is not going to change that. viterin/vek#12
("Support for ARM64?") was closed on 2025-11-03 by the maintainer with "vek runs on ARM64
as pure Go code, but there are no plans to add SIMD acceleration". The last release was
v0.4.3 on 2025-08-14, the last commit was 2025-09-06 (docs only), and there are no open
issues or PRs. Their codegen pipeline (
asm/_cpp→asm2avo.py→ avo →asm/_avx2/*.s)is amd64-only by construction, so NEON would need a whole new path. One fork
(
orneryd/vek, January 2026) did add NEON, but through cgo and C++, never offered itupstream, has no release tags, and its own README reports only 2x on
vek32.Dotbecauseof the per-call cgo transition cost.
What's here
tok/hnsw/kernels.gotok/hnsw/kernels_simd.go//go:build goexperiment.simd, uses thesimdpackagetok/hnsw/kernels_generic.go//go:build !goexperiment.simd, unrolled scalar fallbacktok/hnsw/kernels_test.goThe fallback is what keeps a plain
go build ./...working withoutGOEXPERIMENT=simd,which also matters for anyone consuming this as a library. Vector width is read at run
time rather than assumed, so one implementation covers 128-bit Neon and 256/512-bit AVX.
Also removes the
chewxy/math32andviterin/partialindirect dependencies, which onlycame in through vek.
Numbers
darwin/arm64, 768 float32 dimensions, ns/op:
GOEXPERIMENT=simdWorth noting the middle column: even without the experiment enabled, the fallback beats
vek by 1.9x on cosine and ~3.9x on dot and euclidean. The scalar loop vek falls back to is latency-bound on the
floating-point accumulator dependency chain, so independent partial sums buy most of the
win before any vectorization.
Go 1.27 codegen finding (would appreciate a second pair of eyes)
While measuring the fallback I hit something that is probably worth reporting upstream.
Go 1.27.0 appears to regress the
s += a[i] * b[i]accumulation pattern on arm64 byabout 1.6x. At 768 dimensions the inline-indexed form compiles to a 253ns loop under
go1.27.0, where go1.26.5 compiled the identical source at ~155ns.
I tried to break the result and could not: interleaved runs to rule out thermal drift,
same
GOARM64=v8.0baseline on both toolchains, identical FP instruction mix (5xFMADDSeither way, so FMA fusion is happening), identical bounds-check counts under
-d=ssa/check_bce, and no dependence onGOEXPERIMENT=simd. Same instructions, differentschedule.
Hoisting the loads into locals first recovers it completely (158ns, matching go1.26.5):
So the dot kernels here hoist deliberately, with a comment saying why. The euclidean and
cosine kernels were never affected, because they already hoist via their difference and
product temporaries. That asymmetry is what made the regression visible in the first
place: fallback
dotwas measuring slower thaneuclideanSqdespite doing strictly lesswork per element.
Two caveats before anyone files this: I only measured on Apple Silicon, so it needs
confirming on linux/arm64 and amd64. And it is worth grepping for other hot FP
accumulation loops in the tree that hit the same shape, since I only fixed the ones this
PR touches.
Behaviour change to flag
Results are no longer bit-identical to the previous implementation. Multiple
accumulators reassociate the partial sums, and the SIMD path additionally uses fused
multiply-add. Relative error against a float64 reference stays within ~4e-7, which is far
below the resolution at which ranking decisions change, but any test asserting exact float
equality on scores would need a tolerance. The tests here use
InEpsilon/InDeltathroughout.
One other detail that looks incidental but is load-bearing: each kernel reslices
btolen(a). That halves the bounds checks (it eliminates the ones onb) and is worth ~11%.It is not a length guard, though. A short subslice of a longer array reslices back
within capacity and silently reads past its length, so
applyDistanceFunctionremains theonly place length equality is actually enforced. The contract doc and a test both spell
this out so it does not become a latent surprise.
euclideanDistanceSqis renamed toeuclideanDistance, since it always returned thesquare-rooted value and
distance_thresholdcompares against it in the metric domain.Behaviour is unchanged; the name just now matches what it does.
Testing
boundary cases the unrolls and partial vector loads have to handle (0, 1, 3, 5, 7, 15,
17, 31, 33, 63, 65, and up).
input via its
checkNotEmpty; these return 0 (or NaN for cosine's 0/0) instead.purpose; sizing it from
Len()would heap-allocate on every distance computation, in thehottest loop in vector search.
GOEXPERIMENT=simd, plusposting,schema, andtypes.build,vet, andgofmtclean.Open question for reviewers
Should CI set
GOEXPERIMENT=simdfor release builds? Without it we get the fallback,which is still a solid win over vek but leaves roughly 1.5x on the table. Against that,
the
simdAPI is explicitly documented as not yet stable, so pinning release artifacts toan experiment has its own cost. I do not have a strong opinion and would rather it be a
deliberate call than a default.
Follow-ups, deliberately not bundled here
DistanceThresholdcomparison sites to square the threshold instead.(129ns → 108ns, using this PR's numbers). Changes stored-data semantics, so it deserves its own
discussion.
similar_toworkload. Every number above is kernel-level; the share ofquery latency that is actually distance math, as opposed to posting-list fetch, is still
unmeasured, and that is what decides how much of this shows up end to end.
Checklist
Conventional Commits syntax, leading
with
fix:,feat:,chore:,ci:, etc.🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.