Skip to main content

Deploy to mobile

Your code does not change. The program from part 4 of the series, byte for byte, compiled for Android and pushed to a phone, prints the same numbers there that it printed on your workstation, on the phone's GPU when it has one and on its CPU otherwise. This page is the complete path: cross-compile, push, run, and the few lines of Kotlin an app adds around it.

You need the bundle (its android-arm64 dist ships the CPU and Vulkan backends), the Android NDK, and a phone with USB debugging enabled.

1. Cross-compile

Same project, same main.cpp. The configure line adds the NDK's toolchain file and the ABI; the bundle's CMake package selects the Android dist on its own:

cmake -S . -B build-android \
-DCMAKE_TOOLCHAIN_FILE="$ANDROID_NDK/build/cmake/android.toolchain.cmake" \
-DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-28 \
-DClikaRT_DIR="$CLIKART_BUNDLE_DIR/cmake"
cmake --build build-android
-- ClikaRT 0.1.0: dist android-arm64 (backends: cpu;vulkan)

2. Push and run

The binary plus the two libraries from the Android dist go to the device; the phone needs nothing else installed:

adb shell mkdir -p /data/local/tmp/clikart
adb push build-android/hello \
"$CLIKART_BUNDLE_DIR/lib/libClikaRT.so" \
"$CLIKART_BUNDLE_DIR/lib/libClikaRT_vulkan.so" \
/data/local/tmp/clikart/
adb shell "cd /data/local/tmp/clikart && chmod +x hello && LD_LIBRARY_PATH=. ./hello"

On a Galaxy S24 Ultra:

y = Tensor(shape=[2, 4], dtype=Float32, device=Vulkan:0, numel=8, data=[4.25, 4.25, 4.25, 4.25, 4.25, 4.25, ...])
row means = [4.25, 4.25] (expected 8*1*0.5 + 0.25 = 4.25)

The workstation printed device=CUDA:0; the phone prints device=Vulkan:0. Same program, same numbers. pick_device() from part 3 found the phone's GPU the same way it found the workstation's; on this phone the part 3 discovery loop lists:

CPU 0: ARM
Vulkan 0: Adreno (TM) 750

3. From an app: a few lines of Kotlin

ClikaRT is a C++ library, and an Android app reaches it through the app's own native code. Kotlin loads your library and calls your function; ClikaRT stays on the native side:

object Pipeline {
init { System.loadLibrary("pipeline") }
external fun run(): String
}
pipeline_jni.cpp
#include <jni.h>

#include <ClikaRT/clika_rt.h>

using ClikaRT::DataType;
using ClikaRT::Tensor;
namespace ops = ClikaRT::ops;

extern "C" JNIEXPORT jstring JNICALL
Java_com_example_app_Pipeline_run(JNIEnv* env, jobject) {
const Tensor x = Tensor::ones({2, 8}, DataType::Float32);
const Tensor w = Tensor::full({4, 8}, 0.5, DataType::Float32);
const Tensor y = ops::relu(ops::linear(x, w));
return env->NewStringUTF(y.to_string().c_str());
}

The CMake side adds one target next to hello:

add_library(pipeline SHARED pipeline_jni.cpp)
target_link_libraries(pipeline PRIVATE ClikaRT::ClikaRT)

Beneath the JNI boundary the native side is whatever you built: C++, C and Rust all compile for arm64. Kotlin code can also skip the custom library entirely: the io.clika:clika-runtime artifact ships its own JNI bridge and calls the runtime directly from the app's jniLibs. Python stays on the server and desktop side.

Every platform works this way: one bundle, one toolchain file, the same code. A Jetson needs no cross-compile at all: it is an arm64 Linux machine and runs the linux-arm64 build directly. The system requirements table lists the platforms and their backends. The tutorial ends here; what to read next.