Lab K1: Build the k-NN Plugin from Source
Background
Every other lab in this section assumes you can build and run the k-NN plugin. This lab
earns you that. The k-NN repo (opensearch-project/k-NN) is the most structurally unusual
project in the OpenSearch world: it has two build systems that must agree — a Gradle
build that compiles the Java (src/main/java/org/opensearch/knn/...) and a CMake build
under jni/ that compiles C++ glue and links the bundled faiss and nmslib
libraries into shared objects (libopensearchknn_faiss, libopensearchknn_nmslib,
libopensearchknn_common, and more). Gradle drives CMake; if either side is misconfigured,
you get cryptic failures that look nothing like a normal Java compile error.
On top of that, k-NN is an out-of-repo plugin: it does not live inside the OpenSearch
core tree, so it builds against published core artifacts. Its build.gradle pins an
opensearch_version (e.g. 3.6.0-SNAPSHOT), and if no matching artifact exists in your
local Maven cache, you must publish core yourself first.
In this lab you clone the repo, initialize the native submodules, build the JNI libraries with CMake, build the plugin with Gradle, run its unit and integration tests, and install the result into a local OpenSearch distribution. By the end you will have a node running with a plugin you compiled — native code and all.
Note: The cluster manager (formerly master) is the node that owns cluster state. It is mostly off-stage in this lab — building and installing a plugin is a per-node concern — but it reappears the moment you start a multi-node cluster, because every node must run the same plugin version or the cluster will not form.
Read k-NN architecture and native integration and memory alongside this lab; they are the conceptual map for the two trees you are about to build. The core analogue is Lab 1.1: Build OpenSearch from Source — do that first if you have not; the JDK/toolchain/Gradle-wrapper mechanics carry over.
Why This Lab Matters for Contributors
- You cannot fix a k-NN issue, run its tests, or reproduce a bug report without a working from-source build. This is the gate to every other contribution.
- The dual Gradle+CMake build is the single biggest source of "I can't even get it to compile" pain for new k-NN contributors. Understanding which build owns which failure is half the battle.
- The out-of-repo-plugin model (build against published core artifacts,
publishToMavenLocalwhen there is no published snapshot) is how every OpenSearch plugin outside the core repo is built — k-NN, SQL, security, anomaly-detection. Learn it once here. - Installing your own build into a distribution and seeing it in
_cat/pluginsis the end-to-end loop you will run hundreds of times.
Prerequisites
- A clean OpenSearch core build (you have completed
Lab 1.1 and
./gradlew assembleworks in the core repo). - JDK 21 (the k-NN build, like core, targets JDK 21). The repo's bundled JDK is fine.
- A C++ toolchain:
gcc/g++orclang/clang++, plusmake. - CMake ≥ 3.24 (the
jni/CMakeLists.txtdeclarescmake_minimum_required(VERSION 3.24.0)— confirm withcmake --version). gitwith submodule support, and enough disk (the faiss/nmslib submodules plus their build artifacts are several GB).- On macOS:
libomp(OpenMP) for faiss; on Linux: a BLAS/LAPACK implementation may be pulled in by faiss. Checkjni/CMakeLists.txtand the repo'sDEVELOPER_GUIDE.mdfor the exact platform prerequisites — they drift between versions.
# Verify the toolchain before you start. Any missing tool here = a build failure later.
java -version # want 21.x
cmake --version # want >= 3.24
g++ --version || clang++ --version
git --version
Step-by-Step Tasks
Step 1: Clone the repo WITH its submodules
This is the number-one "k-NN won't build" cause, so get it right first. faiss and nmslib
are git submodules under jni/external/. A plain git clone leaves them empty, the
Java side builds fine, and then the CMake step dies with errors about missing faiss headers.
mkdir -p ~/src/oss-repos && cd ~/src/oss-repos
# Clone AND initialize submodules in one shot:
git clone --recursive https://github.com/opensearch-project/k-NN.git
cd k-NN
# If you already cloned without --recursive, fix it now:
git submodule update --init --recursive
# Confirm the externals are actually populated (not empty directories):
ls jni/external/ # expect: faiss nmslib (plus possibly gtest)
ls jni/external/faiss | head # expect faiss's own source tree, NOT an empty dir
Warning:
jni/external/faissbeing an empty directory is the signature of the missing-submodule failure. The CMake config step also tries to self-heal — thejni/cmake/init-faiss.cmakescript runsgit submodule update --init -- external/faissif it can't find faiss — but do not rely on that; initialize submodules explicitly.
Step 2: Read the version contract
k-NN builds against a specific OpenSearch core version. Find it before anything else; a version mismatch here is the second-most-common build failure.
# The pinned core version (e.g. "3.6.0-SNAPSHOT"):
grep -n "opensearch_version" build.gradle | head
# opensearch_version = System.getProperty("opensearch.version", "3.6.0-SNAPSHOT")
# The build pulls build-tools and core from this version's published artifacts:
grep -n "build-tools\|opensearch_group\|repositories" build.gradle | head
The rule: the plugin's opensearch_version must match a core artifact that exists in
your Maven repositories. For a released version, the public Maven repo has it. For a
-SNAPSHOT, you usually need to publish core locally (Step 3).
Step 3: Publish core to Maven local (only if needed)
If opensearch_version is a -SNAPSHOT that isn't on the OpenSearch snapshot Maven repo,
build it from the core checkout that matches and publish it to your local ~/.m2:
# In your OpenSearch CORE checkout (the one from Lab 1.1), on the matching version/branch:
cd ~/src/oss-repos/OpenSearch
./gradlew publishToMavenLocal -Dbuild.snapshot=true
# This installs org.opensearch:opensearch:<version>, build-tools, etc. into ~/.m2/repository.
ls ~/.m2/repository/org/opensearch/opensearch/ 2>/dev/null
Then point the k-NN build at the local artifacts. The build already adds mavenLocal() in
most versions; if you need to override the version explicitly:
cd ~/src/oss-repos/k-NN
./gradlew assemble -Dopensearch.version=3.6.0-SNAPSHOT
Note: You only need this if the plugin can't resolve core. Try the build first (Step 5); if Gradle fails with
Could not resolve org.opensearch:opensearch:<version>, come back and publish core to Maven local. Releases-against-released-core don't need this.
Step 4: Build the native JNI libraries with CMake
The native build is a CMake project under jni/. In the normal flow Gradle drives CMake
for you (Step 5), but build it standalone once so you understand what Gradle is doing and
can diagnose native failures in isolation.
The Gradle task cmakeJniLib runs the configure step and buildJniLib runs the
compile step. Read exactly what they invoke:
grep -n "cmakeJniLib\|buildJniLib\|KNN_PLUGIN_VERSION\|jni/build\|--target" build.gradle | head
You will see the configure command is essentially:
# Configure: -S = source dir, -B = build dir. KNN_PLUGIN_VERSION must match opensearch_version.
cmake -S jni -B jni/build \
-DKNN_PLUGIN_VERSION=3.6.0-SNAPSHOT \
-DCMAKE_POLICY_VERSION_MINIMUM=3.5
...and the build command targets the named libraries:
# Compile only the native libs, in parallel:
cmake --build jni/build \
--target opensearchknn_faiss opensearchknn_common opensearchknn_nmslib opensearchknn_simd \
--parallel "$(nproc 2>/dev/null || sysctl -n hw.ncpu)"
Or, far simpler, let Gradle do it:
./gradlew buildJniLib # runs cmakeJniLib (configure) then the native compile
When it finishes, find the artifacts. The shared-library suffix is platform-specific
(.so on Linux, .dylib on macOS, .dll on Windows):
find jni/build -name "libopensearchknn_*.so" -o -name "libopensearchknn_*.dylib" 2>/dev/null
# .../libopensearchknn_faiss.{so,dylib}
# .../libopensearchknn_common.{so,dylib}
# .../libopensearchknn_nmslib.{so,dylib}
# .../libopensearchknn_util.{so,dylib} (a shared utility lib the others link)
# .../libopensearchknn_simd.{so,dylib} (SIMD compute helpers)
| Shared library (CMake target) | What it wraps |
|---|---|
opensearchknn_faiss | the bundled faiss C++ library + the plugin's faiss JNI glue (faiss_wrapper.cpp, org_opensearch_knn_jni_FaissService.cpp) |
opensearchknn_nmslib | the bundled nmslib library + nmslib JNI glue (gated; nmslib is deprecated) |
opensearchknn_common | shared JNI utilities (org_opensearch_knn_jni_JNICommons.cpp) used by both engines |
opensearchknn_util | low-level JNI helpers/exception translation that the others link against |
opensearchknn_simd | SIMD compute helpers backing SimdVectorComputeService |
Note: The verified-facts shorthand for this build is "three libraries" (
faiss/nmslib/common). The real CMake build emits more (util,simd), and faiss/nmslib are gated byAVX2_ENABLED/AVX512_ENABLED/feature flags. Alwaysgrep "set(TARGET_LIB" jni/CMakeLists.txtto see the current target list rather than trusting any doc — including this one.
Step 5: Build the whole plugin
Now the full build — Gradle compiles the Java, invokes the CMake native build, and packages the installable zip:
./gradlew assemble # Java + native + the plugin zip (no tests)
# OR the fuller build that also runs checks:
./gradlew build # assemble + precommit + tests
# Find the installable plugin zip:
find build/distributions -name "opensearch-knn-*.zip"
# build/distributions/opensearch-knn-3.6.0-SNAPSHOT.zip
The fastest inner loop — a node with the plugin already loaded, no manual install:
./gradlew run # builds + starts a single node with k-NN on the classpath
# In another terminal:
curl -s 'localhost:9200/_cat/plugins?v'
# name component version
# ... opensearch-knn 3.6.0-SNAPSHOT
Step 6: Run the unit tests
# All Java unit tests:
./gradlew test
# A focused run (much faster while iterating):
./gradlew test --tests "org.opensearch.knn.index.mapper.KNNVectorFieldMapperTests"
./gradlew test --tests "*KNNQueryBuilderTests"
# Reproduce a flaky/seeded failure exactly (OpenSearch/Lucene test framework uses seeds):
./gradlew test --tests "*KNNQueryTests" -Dtests.seed=DEADBEEF
There are also native (C++) tests built and run via a separate task — the JNI glue has its own gtest suite:
grep -n "buildJniTest\|jniTest\|gtest" build.gradle | head
./gradlew buildJniTest # builds the native libs, then runs the C++ JNI tests
Step 7: Run the integration tests
Integration tests spin up a real testClusters node with the plugin installed and exercise
it over REST. They depend on buildJniLib, so the native libraries must build first.
# The main integ suite (starts a real node):
./gradlew integTest
# k-NN also has multi-node and remote-index-build integ variants — grep to see them all:
grep -n "RestIntegTestTask\|integTest\b\|integTestMultiNode\|integTestRemoteIndexBuild" build.gradle | head
# Run just one integ test class while iterating:
./gradlew integTest --tests "*KNNRestTestCase*" 2>/dev/null || \
./gradlew integTest --tests "org.opensearch.knn.*IT"
Note: Integration tests are slow (they boot a JVM cluster) and need the native libs. If
integTestfails immediately with anUnsatisfiedLinkError, yourbuildJniLibstep didn't produce the libraries for your platform — fix Step 4 first.
Step 8: Install the built plugin into a local distribution
./gradlew run is the dev loop, but to mimic a real deployment, install your zip into an
actual OpenSearch distribution with bin/opensearch-plugin:
# 1. Produce (or locate) an OpenSearch distribution of the MATCHING version.
# From your core checkout:
cd ~/src/oss-repos/OpenSearch
./gradlew localDistro
DISTRO=$(find distribution/archives -maxdepth 2 -name "opensearch-*" -type d | head -1)
echo "Distribution at: $DISTRO"
# 2. Install the k-NN zip you built (use a file:// URL to a local zip):
ZIP=$(find ~/src/oss-repos/k-NN/build/distributions -name "opensearch-knn-*.zip" | head -1)
"$DISTRO/bin/opensearch-plugin" install "file://$ZIP"
# -> "Installing file://.../opensearch-knn-....zip"
# -> "Continue with installation? [y/N]" (the plugin has native code; answer y)
# 3. Start the node and confirm the plugin loaded:
"$DISTRO/bin/opensearch" &
sleep 20
curl -s 'localhost:9200/_cat/plugins?v'
# name component version
# node-0 opensearch-knn 3.6.0-SNAPSHOT
Step 9: Smoke-test the install end to end
Prove the native path works, not just that the plugin loaded:
# Create a tiny k-NN index, index two vectors, query, and warm up.
curl -s -XPUT 'localhost:9200/k1demo?pretty' -H 'Content-Type: application/json' -d '{
"settings": { "index.knn": true },
"mappings": { "properties": {
"v": { "type": "knn_vector", "dimension": 2,
"method": { "name": "hnsw", "engine": "faiss", "space_type": "l2" } }
}}
}'
curl -s -XPOST 'localhost:9200/k1demo/_bulk?refresh=true' -H 'Content-Type: application/x-ndjson' -d '
{ "index": { "_id": "1" } }
{ "v": [1.0, 1.0] }
{ "index": { "_id": "2" } }
{ "v": [9.0, 9.0] }
'
# Load the faiss graph into native memory, then query it:
curl -s -XPOST 'localhost:9200/_plugins/_knn/warmup/k1demo?pretty'
curl -s -XPOST 'localhost:9200/k1demo/_search?pretty' -H 'Content-Type: application/json' -d '{
"size": 1,
"query": { "knn": { "v": { "vector": [1.1, 0.9], "k": 1 } } }
}'
# Expect _id "1" as the nearest neighbor.
# Confirm native memory accounting works (faiss graph is OFF-heap):
curl -s 'localhost:9200/_plugins/_knn/stats?pretty' | grep -E 'graph_memory|hit_count|miss_count|load'
Implementation Requirements / Deliverables
-
A
k-NNclone withjni/external/faissandjni/external/nmslibpopulated (submodules initialized). -
The native libraries built:
libopensearchknn_faiss,libopensearchknn_common,libopensearchknn_nmslib(plusutil/simd) found underjni/build. -
./gradlew assembleproducesbuild/distributions/opensearch-knn-<version>.zip. -
./gradlew testpasses (or you can name and explain any pre-existing flaky failure). -
./gradlew integTestruns a real node and passes its suite. -
The plugin installed into a
localDistroviabin/opensearch-plugin install, listed in_cat/plugins. -
A working faiss query against a 2-D index, with
_plugins/_knn/statsshowing non-zerograph_memory_usageafter warmup. - You can state which build (Gradle vs CMake) owns a given failure mode.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
CMake errors about missing faiss headers; jni/external/faiss empty | submodules not initialized | git submodule update --init --recursive |
Could not resolve org.opensearch:opensearch:<version> | no matching core artifact in Maven repos | publish core: ./gradlew publishToMavenLocal in the matching core checkout |
CMake 3.24 or higher is required | toolchain CMake too old | upgrade CMake; check cmake --version against cmake_minimum_required in jni/CMakeLists.txt |
UnsatisfiedLinkError at node startup or in integTest | native lib not built / wrong arch / missing toolchain | run ./gradlew buildJniLib; find the libopensearchknn_* artifacts; confirm they match your CPU arch |
faiss CMake step fails on OpenMP::OpenMP_CXX (macOS) | OpenMP/libomp not installed | install libomp (e.g. via Homebrew); re-run cmakeJniLib |
| faiss build fails on BLAS/LAPACK (Linux) | missing BLAS dev package | install your distro's BLAS/LAPACK dev package; see jni/CMakeLists.txt |
./gradlew clean then native rebuild fails repeatedly | stale CMake cache / patch state | clean removes jni/build; if patches re-apply, check apply_lib_patches/commit_lib_patches flags in build.gradle |
| Plugin install prompts and aborts | the install confirmation for a plugin with native code | answer y, or use -b/batch flag for non-interactive installs |
plugin [...] is incompatible with version [...] | plugin built against a different core version than the distro | rebuild the plugin against the distro's exact version, or build a matching distro |
| Java compiles but native step never runs | you ran only compileJava, not assemble/buildJniLib | the native build is a separate task chain — run assemble or buildJniLib |
Rule of thumb for which build owns a failure: a stack trace with
org.opensearch.knn.*Java frames and a Gradle task name → the Gradle/Java side. A message mentioningcmake,make, faiss/nmslib headers,.so/.dylib, OpenMP, BLAS, orUnsatisfiedLinkError→ the CMake/native side. Diagnose them with different tools.
Expected Output
# After ./gradlew buildJniLib
> Task :cmakeJniLib
> Task :buildJniLib
BUILD SUCCESSFUL
# Native artifacts (macOS example)
jni/build/release/libopensearchknn_faiss.dylib
jni/build/release/libopensearchknn_common.dylib
jni/build/release/libopensearchknn_nmslib.dylib
# After ./gradlew assemble
build/distributions/opensearch-knn-3.6.0-SNAPSHOT.zip
# _cat/plugins on the node with your build installed
name component version
node-0 opensearch-knn 3.6.0-SNAPSHOT
# Smoke query result
"hits": [ { "_id": "1", "_score": 0.83..., "_source": { "v": [1.0, 1.0] } } ]
# _plugins/_knn/stats after warmup (off-heap memory is non-zero)
"graph_memory_usage": 1,
"hit_count": 1,
"miss_count": 1
Stretch Goals
- Build a single engine. The build supports
-Pknn_libs=...to limit the native targets. Build only faiss (skip nmslib) and confirm the nmslib.sois absent.grep -n "knn_libs" build.gradleto find the property. - Toggle SIMD. Re-run
cmakeJniLibwith-Davx2.enabled=false(or the property the build reads — grep foravx2_enabled) and observe the configure flags change. Note thatPlatformUtils.isAVX2SupportedBySystem()is what selects the runtime path. - Inspect the JNI glue.
ls jni/srcand readfaiss_wrapper.cpp's query function. Match itsJava_org_opensearch_knn_jni_FaissService_*name to thenativemethod declared insrc/main/java/org/opensearch/knn/jni/FaissService.java. - Run the C++ tests directly. After
buildJniTest, find the gtest binary underjni/buildand run it standalone to see the native test output without Gradle in the way. - Diff the published vs local artifact. After
publishToMavenLocal, compare the core jar your plugin resolved against the one in the distribution you installed into — confirm they are the same version, which is why the plugin loads. - Time the build. Run
./gradlew assemble --profileand read the generated report. Notice how much of the wall-clock is the CMake/native step versus Java compilation.
Validation / Self-check
- Name the two build systems k-NN uses, what each is responsible for, and how Gradle and CMake relate (which one invokes the other, and via which tasks).
- Why does a fresh
git clone(without--recursive) build the Java but fail the native step? Which directory tells you submodules are missing? - What does
opensearch_versioninbuild.gradlecontrol, and when do you needpublishToMavenLocalin the core repo before the plugin will build? - List the shared libraries the CMake build emits and what each wraps. Why are faiss and nmslib kept in separate libraries rather than one?
- Given a failure message, decide whether the Gradle/Java build or the CMake/native build owns it. Give one concrete example of each.
- What is the difference between
./gradlew runand installing the zip withbin/opensearch-plugin install? When would you use each? - After warming up a faiss index, which API proves the native graph is resident in off-heap memory, and which field do you read?
When you can build both trees, run all the test tiers, install your own zip, and serve a faiss query, you are ready to read code that actually runs. Continue to Lab K2: Trace a k-NN Query, and keep k-NN architecture and native integration and memory open as your map.