Cross-compiling a C++ Library Using the Android NDK (Part I)
A common theme in the world of MLOps is the development of machine learning models on state-of-the-art cloud computing environments or desktop Linux computers with GPU capabilities and x86-64 hardware. However, we often do so with the intention of eventually using these models on edge devices such as smartphones, tablets, or embedded systems. These edge devices typically have completely different hardware, and actually deploying compute-heavy C/C++ library functionality to these devices represents a non-trivial step in the development cycle.
Nearly every tutorial detailing the deployment of native C/C++ code to Android makes heavy use of Android Studio’s integrated development environment (IDE) or a similar IDE such as Eclipse. While these IDEs provide a wide range of features, they can also act as a black box, obscuring details of the compilation and deployment process. In this blog post, we take a different approach, avoiding the Android Studio IDE entirely in order to detail the deployment process of C/C++ programs to an Android mobile device in a transparent way. The only things we’ll need are the Android SDK/NDK binary executables, CMake, and a terminal.
Your Android phone is a Linux box
User-interface (UI) and application development in Android is typically done in Java and/or Kotlin, which are the primary interfaces for interacting with the Android operating system. Despite that fact, as stated in the Android documentation, the Android kernel is based on a Linux Long Term Supported (LTS) kernel.
Therefore, in some ways, your Android phone operates like a mobile Linux machine, and heroic projects like rawdrawandroid underscore the fact that Android apps can be created in pure C, without Java, Kotlin, or the Java Native Interface (JNI) at all. Furthermore, it’s possible to interact with the Android device like a linux machine, without the UI/UX components.
Disclaimer: we won’t be using the Android Studio IDE, but it is necessary to install the Android Studio SDK from their website to obtain access to the SDK and NDK cross-compilation tools, as well as the platform tools.
First, locate the installation location of the Android SDK on your machine—for example, on my system it’s in /home/user/Android/Sdk/
. This directory will have a platform-tools
subdirectory, which includes a useful binary executable called adb
. This is the Android Debug Bridge, a “versatile command-line tool that lets you communicate with a device” according to the Android developer documentation. Once we’ve physically connected an Android device to our Linux machine (from here on, I’ll refer to the desktop machine as the “workstation”) via USB cable and allowed access to the phone, we can use adb
to open a shell within the device.
workstation >> ls
Desktop
Downloads
... [ this is your workstation filesystem ]
workstation >> /path/to/adb shell
android-device >> ls
acct efs prism
apex etc proc
audit_filter_table init product
bin init.container.rc sdcard
... [ this is the filesystem on your android device ]
As you’re now accessing your Android device as if it were a remote server, you can inspect the filesystem and run Linux commands (although most Android Linux systems are not as full-featured as a standard Ubuntu distribution).
Cross-compiling and deploying a trivial C++ program
Having started to interact with the Android device as if it were a remote server, can we compile a binary executable and run it natively on our mobile device? In our adb
shell,
workstation >> /home/user/Android/Sdk/platform-tools/adb shell
android-device >> which gcc
android-device >>
from which we can see that the gcc
compiler is not directly available on the Android device. However, we don’t want to use our Android device itself as a development environment, rather, we’d like to compile an executable on our desktop machine which can be run on the Android device’s operating system (i.e., “natively”). This process of compiling an executable on a workstation which can be run as-is on an adroid device is what we mean by “cross-compilation.” To that end, on our workstation, we compile a simple C++ program
// hello_android.cc
#include <iostream>
int main() {
std::cout << "paranoid android" << std::endl;
return 0;
}
with the g++
compiler
g++ hello_android.cc -o hello_x86-64
and move the binary executable to the /data/local/tmp
directory of our Android device’s filesystem with the adb push
command:
workstation >> /path/to/adb push hello_x86-64 /data/local/tmp
hello_x86-64: 1 file pushed, 0 skipped. 233.0 MB/s (16536 bytes in 0.000s)
Now we open an adb shell
and attempt to run the executable:
workstation >> /home/user/Android/Sdk/platform-tools/adb shell
android-device >> cd /data/local/tmp; ls
hello_x86-64
android-device >> ./hello_x86-64
/system/bin/sh: ./hello_x86-64: not executable: 64-bit ELF file
What happened? When we compiled the suggestively-named executable hello_x86-64
, we did so for our workstation’s hardware, namely an x86-64 (x86 instruction set, 64 bit) architecture, which we can see by running the Linux file
command on our device.
android-device >> file hello_x86-64
hello_x86-64: ELF shared object, 64-bit LSB x86-64, dynamic (/lib64/ld-linux-x86-64.so.2), BuildID=ec4ac, not stripped
^^^^^^^
However, our Android device does not use this hardware and therefore can not run the executable; this is because C++ compilation is hardware-specific. In fact, the hardware-specific nature of languages like C and C++ is one of the reasons that Java’s “write once, run anywhere” characteristic can be advantageous for mobile UI/UX development. Fortunately, the Android SDK comes complete with a set of (cross-)compilers that can compile an executable on our workstation for targeted deployment to the whatever hardware our Android device happens to be using. If you locate the installation location of the Android NDK bundle, these compilers are available as binary executables. For example, on my workstation, the NDK version 26.2 compilers are in the following directory:
workstation >> cd /home/user/Android/Sdk/ndk/26.2.11394342/toolchains/llvm/prebuilt/linux-x86_64/bin
workstation >> ls
aarch64-linux-android33-clang armv7a-linux-androideabi31-clang i686-linux-android23-clang++ x86_64-linux-android23-clang++
aarch64-linux-android33-clang++ armv7a-linux-androideabi31-clang++ i686-linux-android24-clang x86_64-linux-android24-clang
... [ and many more ] ...
Looking up the hardware specification of the particular mobile device I’m using, I see that it uses an aarch64
(also sometimes called arm64
) architecture. So let’s compile our C++ code with the aarch64
version of the clang++
compiler, and push the resulting executable to the device.
workstation >> /path/to/aarch64-linux-android34-clang++ hello_android.cc -o hello_aarch64
workstation >> /path/to/adb push hello_aarch64 /data/local/tmp
workstation >> adb shell
android-device >> cd /data/local/tmp; file *
hello_aarch64: ELF shared object, 64-bit LSB arm64, dynamic (/system/bin/linker64), for Android 34, built by NDK r26c (11342), not stripped
hello_x86-64: ELF shared object, 64-bit LSB x86-64, dynamic (/lib64/ld-linux-x86-64.so.2), BuildID=ec52ac, not stripped
Now from the output of file
, we see that this new executable has been compiled for arm64
, and, this time, when we attempt to run it,
android-device >> ./hello_aarch64
paranoid android
we see that the program executes as expected on the Android device.
Details: to avoid the need for the Android system linker to resolve the dependencies for more complicated problems, it may be necessary to compile your C/C++ code as a static executable.
To summarize, we have successfully cross-compiled and deployed C++ code for Android. We didn’t need to build an app, write Java / Kotlin code, or interact with the UI at all in order to run the executable—using these tools we were able to execute the code on the OS itself, i.e., we were able to run the program “natively.”
Cross-compiling and deploying a C++ program with library dependencies
Deploying simple programs like the one in the previous section can go a long way towards demystifying the cross-compilation and deployment process. But typically, we are interested in compiling more complicated software with its own library dependencies. In such cases, attempting to use the NDK compilers directly via the command line is a labor-intensive and error-prone approach. Rather, we’d like to invoke the cross-compilers within a modern build system. In this post, we’ll use cmake
to compile and deploy an executable making use of Google’s sentencepiece
text tokenization library.
The first step is to write some code making use of the sentencepiece
C++ API. In a new directory, we write some example code:
// example.cc
#include <iostream>
#include <sentencepiece_processor.h>
void call_sentencepiece() {
std::cout << "CLIKA::call_sentencepiece" << std::endl;
sentencepiece::SentencePieceProcessor processor;
std::vector<std::string> pieces;
processor.Encode("This is a test.", &pieces);
int counter = 0;
for (const std::string &token : pieces) {
std::cout << "token " << counter << ": " << token << std::endl;
counter++;
}
}
int main() {
std::cout << "hello, sentencepiece" << std::endl;
call_sentencepiece();
}
As the sentencepiece
library is a dependency, we need to clone its source code to the same directory. We will also create a build
directory in which to do an out-of-source build:
workstation >> git clone git@github.com:google/sentencepiece.git
workstation >> mkdir build
We specify the library dependencies and the C++ standard in CMakeLists.txt
, as is typical of cmake
builds:
# CMakeLists.txt
cmake_minimum_required(VERSION 3.1)
project(sentencepiece-example)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
# add sentencepiece as a subdirectory which will be built from source
add_subdirectory(sentencepiece)
include_directories(sentencepiece/src)
# link our executable to the sentencepiece-static cmake target
add_executable(example example.cc)
target_link_libraries(example sentencepiece-static)
We also need to provide arguments to the cmake
command so that it knows to use the appropriate Android NDK cross-compilers rather than the default compilers on our workstation. Since there are lots of options for configuring the cmake
build, we will put them in their own standalone shell script, xcompile_cmake.sh
, which calls the Android NDK-specific toolchain and the clang
compiler, as well as specifying the arm64-v8a
as the target architecture on which we will deploy our executable:
# xcompile_cmake.sh
/path/to/Android/Sdk/cmake/3.22.1/bin/cmake .. \
-DANDROID_ABI=arm64-v8a \
-DANDROID_NDK=/path/to/Android/Sdk/ndk/26.2.11394342/ \
-DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_TOOLCHAIN_FILE=/path/to/Android/Sdk/ndk/26.2.11394342/build/cmake/android.toolchain.cmake \
-DANDROID_NATIVE_API_LEVEL=21 \
-DANDROID_TOOLCHAIN=clang \
-DANDROID_LINKER_FLAGS="-landroid -llog" ..
Details: we are specifying that the source directory is ..
since we will copy this script to the build directory (although we could just as easily make use of the -B
and -S
cmake
flags). We also are linking to the android
and log
libraries so that we can make use of Android utilities.
After these steps, our project directory looks like the following:
workstation >> tree .
├── build/
├── CMakeLists.txt
├── example.cc
├── sentencepiece/
│ ├── README.md
│ ├── src
...
└── xcompile_cmake.sh
Now we’re ready to build the project by first invoking cmake
to generate a Makefile, then calling make
. The binary executable will be in the build
directory, which we can then push to our mobile device and run.
workstation >> cd build; cp ../xcompile_cmake.sh .
workstation >> ./xcompile_cmake.sh
-- The C compiler identification is Clang 17.0.2
-- The CXX compiler identification is Clang 17.0.2
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
... [ additional CMake output ]
-- Configuring done
-- Generating done
workstation >> make -j8
workstation >> cd ..
workstation >> path/to/adb push build/example /data/local/tmp
workstation >> path/to/adb shell
android-device >> cd /data/local/tmp
android-device >> ls
example hello_aarch64 hello_x86-64
android-device >> ./example
hello, sentencepiece
CLIKA::call_sentencepiece
And the executable is able to run the sentencepiece
code.
Details: the reason that no tokenization output is printed is because sentencepiece
tokenizers require a tokenization model to actually tokenize output. Since we didn’t load a model, processor.Encode(...)
returns an empty vector. The point is to show that we were able to use the sentencepiece
library—adb push
can be used to load a HuggingFace tokenization model to the device to perform actual tokenization as a straightforward extension to what we’ve developed so far.
We’ve cross-compiled an executable with complicated dependencies using the cmake
build system and deployed it natively on the Android device operating system. How to use this cross-compiled C++ code in the Java/Kotlin UI/UX ecosystem using the JNI will be the subject of a future post.