Skip to content

perf(knn): replace argsort+slice with top_k for neighbor search, ~5-9x speedup - #344

Merged
josevalim merged 1 commit into
elixir-nx:mainfrom
Ljzn:perf/knn-top-k
Jul 21, 2026
Merged

perf(knn): replace argsort+slice with top_k for neighbor search, ~5-9x speedup#344
josevalim merged 1 commit into
elixir-nx:mainfrom
Ljzn:perf/knn-top-k

Conversation

@Ljzn

@Ljzn Ljzn commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Problem

BruteKNN.brute_force_search/3 finds the K nearest neighbors by fully sorting
all distances and then slicing the first K:

Nx.argsort(distances, axis: 1, type: :u64) |> Nx.slice_along_axis(0, k, axis: 1)

This is O(N log N) per query — sorting all N distances just to retrieve K.
For N >> K, this is wasteful: the sort comparator runs ~N log N times while
only K elements are ultimately needed.

Solution

Replace with Nx.top_k/2 on negated distances, which selects the K smallest
values directly using XLA's native chlo.top_k operator (on EXLA) or a
block-based fallback:

{_values, raw_indices} = Nx.top_k(Nx.negate(distances), k: k)
neighbor_indices = Nx.as_type(raw_indices, {:u, 64})

This reduces the complexity from O(N log N) to O(N log K) per query.

Benchmark

BruteKNN.fit/predict on random 64-dimensional float data (EXLA backend),
averaged over 5 runs:

N (train) Before (ms) After (ms) Speedup
5,000 0.77 0.15 5.1x
50,000 8.85 1.04 8.5x

Speedup increases with N/K ratio, consistent with the complexity class change.

Benchmark script
Mix.install([
  {:nx, path: "/path/to/nx", override: true},
  {:exla, path: "/path/to/exla"},
  {:scholar, path: "."}
], force: true, verbose: false)

Nx.global_default_backend(EXLA.Backend)
Nx.Defn.global_default_options(compiler: EXLA)

key = Nx.Random.key(42)
k = 5
for n <- [5000, 50000] do
  {d, key2} = Nx.Random.normal(key, 0.0, 1.0, shape: {n, 64}, type: {:f, 32})
  {q, _} = Nx.Random.normal(key2, 0.0, 1.0, shape: {1, 64}, type: {:f, 32})
  m = Scholar.Neighbors.BruteKNN.fit(d, num_neighbors: k)
  Scholar.Neighbors.BruteKNN.predict(m, q)
  times = Enum.map(1..5, fn _ ->
    {t, _} = :timer.tc(fn ->
      m2 = Scholar.Neighbors.BruteKNN.fit(d, num_neighbors: k)
      Scholar.Neighbors.BruteKNN.predict(m2, q)
    end); t
  end)
  IO.puts("n=#{n}\\t#{Float.round(Enum.sum(times) / length(times) / 1000, 2)} ms")
end

Correctness

Nx.top_k returns indices of the K largest values; negating the distances
converts "smallest K distances" into "largest K negative distances". The
resulting indices match those from argsort + slice.

Compatibility

This change is transparent:

  • EXLA users benefit from the native chlo.top_k XLA operator
  • BinaryBackend users fall back to argsort + take_along_axis + slice
    (same complexity as before, no regression for small K)
  • All existing types (:u64 indices) are preserved via Nx.as_type

Assisted by DeepSeek V4 Flash

@krstopro krstopro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, good catch, well done!

@josevalim
josevalim merged commit 3619c28 into elixir-nx:main Jul 21, 2026
2 checks passed
@josevalim

Copy link
Copy Markdown
Contributor

💚 💙 💜 💛 ❤️

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants