In a previous blog post, I mentioned how to use CPU-specific instructions in Rust to speed up the Shamir’s Secret Sharing algorithm. In my initial implementation, I only wrote an optimized version for Intel CPUs, but ARM supports similar instructions, so I mentioned that it would be nice to add an optimized implementation for it as well.

However, how to test this ARM-specific implementation? My development laptop runs on Intel, so running unit tests with cargo test would not test the ARM instructions.

I thought of several approaches.

  • Purchasing an ARM-based laptop, which would be quite expensive just to test a few instructions on a specific project.
  • Using an emulator, assuming it correctly implements all the instructions I want to test. For example, the cross crate aims at streamlining that.
  • Or why not using my Android phone, which is real hardware that runs on ARM? I already have one, so no purchase needed.

As you may guess from the title, I went for this last option. What started from testing one instruction on ARM ended up being quite a journey, including patching the Rust compiler! In this first blog post of the series, I’ll review how to compile Rust libraries for Android apps, and making sure we’re targeting the correct CPU architecture.

For reproducibility, you can find my code on GitHub.


Introduction: Building an Android app with the command-line tools

The first step is to create a traditional Java-based Android app. The default IDE for that is Android Studio, which integrates a code editor, the build tools, and plenty of features like a layout editor, a profiler, etc. However, as an IDE, this is heavyweight and may not be suitable for continuous integration (or if you only care about building the app).

Instead, in this section I’ll explain how to compile the app manually with the Android command line tools. I’ll provide the instructions in the Dockerfile format (documentation). If you prefer to use Android Studio you can skip to the next section.

Before we get started, we’ll need a few Debian packages:

  • wget and unzip to download the Android command line tools,
  • build-essential to have a C++ toolchain and be able to build some Rust libraries – you’ll likely have a dependency needing it,
  • openjdk-11-jdk-headless to provide a Java runtime to Gradle, the build tool used by Android.
FROM debian:testing-slim

RUN apt-get update \
    && apt-get install -y \
        wget \
        unzip \
        build-essential \
        openjdk-11-jdk-headless \
        --no-install-recommends \
    && rm -rf /var/lib/apt/lists/*

Let’s also configure a non-root user in our Docker container.

RUN useradd --uid 1000 --create-home --shell /bin/bash dev
USER dev
WORKDIR "/home/dev"

Gradle

The first component we’ll need to install is Gradle, the build tool used by Android.

If your application’s source code already contains a Gradle Wrapper (gradlew script at the root), which is the case by default with Android Studio, you can skip this step.

Although there exists a Debian package for Gradle, in practice it is widely outdated, which causes some issues. We can instead download and unpack it directly – here I’m using the latest release currently available, checking the corresponding SHA-256 checksum after download.

# Set an environment variable for convenience.
ENV GRADLE_ROOT=/home/dev/opt/gradle

RUN mkdir -p ${GRADLE_ROOT}
RUN wget https://services.gradle.org/distributions/gradle-7.5.1-bin.zip -O gradle-7.5.1-bin.zip \
    && sha256sum gradle-7.5.1-bin.zip \
    && echo "f6b8596b10cce501591e92f229816aa4046424f3b24d771751b06779d58c8ec4  gradle-7.5.1-bin.zip" | sha256sum -c - \
    && unzip gradle-7.5.1-bin.zip -d ${GRADLE_ROOT} \
    && rm gradle-7.5.1-bin.zip

# Add the relevant directories to the $PATH.
ENV PATH=${PATH}:${GRADLE_ROOT}/gradle-7.5.1/bin

Android SDK

Next, we download the Android command-line tools, here using the latest version currently available on the Android website.

# Set the ${ANDROID_HOME} variable, so that the tools can find our installation.
# See https://developer.android.com/studio/command-line/variables#envar.
ENV ANDROID_HOME=/home/dev/opt/android-sdk

# Download and extract the command-line tools into ${ANDROID_HOME}.
RUN mkdir -p ${ANDROID_HOME}
RUN wget https://dl.google.com/android/repository/commandlinetools-linux-8512546_latest.zip \
        -O ${HOME}/commandlinetools-linux-8512546_latest.zip \
    && sha256sum commandlinetools-linux-8512546_latest.zip \
    && echo "2ccbda4302db862a28ada25aa7425d99dce9462046003c1714b059b5c47970d8 commandlinetools-linux-8512546_latest.zip" | sha256sum -c - \
    && unzip commandlinetools-linux-8512546_latest.zip -d ${ANDROID_HOME}/cmdline-tools \
    && rm commandlinetools-linux-8512546_latest.zip

# Add the relevant directories to the $PATH.
ENV PATH=${PATH}:${ANDROID_HOME}/cmdline-tools/cmdline-tools/bin:${ANDROID_HOME}/platform-tools

Once these tools are installed, the sdkmanager program is available to install the specific parts of the SDK that we actually need.

  • The first step is to accept the software licenses, by answering “yes” to sdkmanager --licenses.
  • We then install the build tools for Android SDK 33, as well as the native development kit (NDK) which allows implementing part of the app in a native programming language – in our case Rust.
RUN yes | sdkmanager --licenses \
    && sdkmanager --verbose \
        "build-tools;30.0.3" \
        "ndk;25.1.8937393" \
        "platforms;android-33"
ENV NDK_HOME=${ANDROID_HOME}/ndk/25.1.8937393

Running sdkmanager --list_installed should give you something like the following.

Path                 | Version      | Description                     | Location
-------              | -------      | -------                         | -------
build-tools;30.0.3   | 30.0.3       | Android SDK Build-Tools 30.0.3  | build-tools/30.0.3
emulator             | 31.3.12      | Android Emulator                | emulator
ndk;25.1.8937393     | 25.1.8937393 | NDK (Side by side) 25.1.8937393 | ndk/25.1.8937393
patcher;v4           | 1            | SDK Patch Applier v4            | patcher/v4
platform-tools       | 33.0.3       | Android SDK Platform-Tools      | platform-tools
platforms;android-33 | 2            | Android SDK Platform 33         | platforms/android-33

You may wonder why I’m using the build-tools;30.0.3 rather than the latest version (currently 33.0.0). This is because of a tight coupling between the Gradle version, the com.android.tools.build:gradle package mentioned in the project’s build.gradle file, the build tools, and the Android SDK.

In my case, if I installed the build tools version 33.0.0, Gradle would later complain that it couldn’t find the correct build tools. This is also explained by the fact that com.android.tools.build:gradle 7.3.0 depends on Android tools 30.3.0 according to the Maven Repository.

FAILURE: Build failed with an exception.

* What went wrong:
Could not determine the dependencies of task ':app:compileDebugJavaWithJavac'.
Failed to find Build Tools revision 30.0.3

Besides, the com.android.tools.build:gradle package must be compatible with your installed Gradle version, as somewhat explained in this StackOverflow question. If you’re using a different Gradle version, you may explore the options here.

Lastly, only some Android SDK versions are compatible with a given Gradle version. For example, I’ve briefly tested with the currently outdated gradle package from Debian: this Gradle 4.4.1 worked with com.android.tools.build:gradle:3.1.4, build-tools;27.0.3 and Android SDK 27 (platforms;android-27).

With these installation steps, we can already build a regular Android app (without any Rust code), by going into its top-level folder and running the following commands.

echo "sdk.dir=${ANDROID_HOME}" > local.properties
gradle build

This will create compiled APKs in the following locations:

  • app/build/outputs/apk/release/app-release-unsigned.apk,
  • app/build/outputs/apk/debug/app-debug.apk.

Android emulator

If you want to test your app in an Android emulator, you can also install one from the command line tools.

For this, you first need to choose a system image to install via the sdkmanager. I’ve chosen one for Android 29 running on x86-64 (matching my host architecture). There are also newer images, but these require more memory to run.

RUN sdkmanager --verbose "system-images;android-29;default;x86_64"

Next, we create a new Android virtual device.

RUN avdmanager create avd \
    -n test_avd \
    -d pixel \
    -k "system-images;android-29;default;x86_64"

And we can launch it in the Android emulator.

${ANDROID_HOME}/emulator/emulator -verbose -avd test_avd

Running the emulator within Docker requires the following permissions:

  • being a member of the kvm group,
  • having access to the /dev/kvm and /dev/dri files,
  • having access to the X11 environment – I’ve described in this blog post how to do that with Docker.

Starting from version 31.3.12, the Android emulator seems to require more than 7 GB of disk to store the virtual device’s “user data”. This doesn’t seem configurable, but as a workaround you can replace the default emulator by an earlier one, following the instructions in the emulator archive.

Creating an Android application with Rust code

Let’s move on to the gist of this article: compiling Rust code for Android applications.

My starting point was a post from 2017 on Mozilla’s blog describing an experiment to compile a Rust library and embed it into an Android application. However, this post is now 5 years old, and I discovered a mistake affecting the dispatch of Android ABIs. As we will see, this mistake would generally go unnoticed, but would lead to sub-optimal code on 64-bit ARM architectures and potentially affect the CPU-specific instructions I wanted to test!

The next step is to build a Rust library targeting the Android platform. Or rather build one Rust library for each of the currently 4 supported Android ABIs.

Installing and configuring the Rust build tools for Android

Because we install custom Rust targets, we’ll be using the rustup toolchain installer rather than the default Debian packages. The following commands download rustup, verify the checksum and do a bit of cleanup, but the main part is to install the relevant Android targets via rustup target add. Here I’m installing a stable and a nightly Rust compiler.

RUN wget https://sh.rustup.rs -O rustup.sh \
    && sha256sum rustup.sh \
    && echo "173f4881e2de99ba9ad1acb59e65be01b2a44979d83b6ec648d0d22f8654cbce  rustup.sh" | sha256sum -c - \
    && sh rustup.sh -y \
    && rm rustup.sh \
    && rm .profile \
    && ${HOME}/.cargo/bin/rustup target add \
        aarch64-linux-android \
        armv7-linux-androideabi \
        i686-linux-android \
        x86_64-linux-android \
    && ${HOME}/.cargo/bin/rustup toolchain install nightly \
    && ${HOME}/.cargo/bin/rustup target add --toolchain nightly \
        aarch64-linux-android \
        armv7-linux-androideabi \
        i686-linux-android \
        x86_64-linux-android

ENV PATH=${PATH}:/home/dev/.cargo/bin

We then need to configure Rust to find the target-specific linkers, by adding the following lines to the ${HOME}/.cargo/config file. Note that you’ll need to expand the ${NDK_HOME} to an explicit absolute path.

[target.aarch64-linux-android]
linker = "${NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android30-clang"

[target.armv7-linux-androideabi]
linker = "${NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi30-clang"

[target.i686-linux-android]
linker = "${NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/i686-linux-android30-clang"

[target.x86_64-linux-android]
linker = "${NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android30-clang"

Here I’ve used the “Android 30” flavor of the linker to match our build tools for the Java part of the app, but it probably doesn’t matter much.

This configuration file will tell Cargo where to find the relevant linker to cross-compile for the Android NDK, and avoid linker errors like the following.

error: linking with `cc` failed: exit status: 1
  |
...
  = note: /usr/bin/ld: /home/dev/build/android-simd/target/aarch64-linux-android/release/deps/simd.simd.4ca11142-cgu.0.rcgu.o: Relocations in generic ELF (EM: 183)
...
          /usr/bin/ld: /home/dev/build/android-simd/target/aarch64-linux-android/release/deps/simd.simd.4ca11142-cgu.0.rcgu.o: error adding symbols: file in wrong format
          collect2: error: ld returned 1 exit status

error: could not compile `simd` due to previous error

Building the Rust library

Now that we’ve installed the relevant toolchains, we can focus on the library itself.

The main point here is that the Android application will be able to load dynamic libraries (.so files on Linux), so we’ll need to tell the Rust compiler to create a .so file. This can be done via the crate-type field in your Cargo.toml file (see also the linkage section of the Rust reference).

[lib]
crate-type = ["dylib"]

With these steps, we can now use Cargo to compile the library, invoking it with the relevant --target CPU architecture, and the NDK toolchain binaries in the $PATH.

export PATH=$PATH:${NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin
cargo build --target aarch64-linux-android --release
cargo build --target armv7-linux-androideabi --release
cargo build --target i686-linux-android --release
cargo build --target x86_64-linux-android --release

Including the Rust library into the Android app

The last build step is to connect our Rust library to the Android app. This is relatively straightforward, and done by creating a jniLibs/ folder inside of the app/src/main/ of the application’s source code, and putting the libraries (for each architecture) there.

cd app/src/main
mkdir -p jniLibs
mkdir -p jniLibs/arm64-v8a
mkdir -p jniLibs/armeabi-v7a
mkdir -p jniLibs/x86
mkdir -p jniLibs/x86_64

ln -sf <android-library>/target/aarch64-linux-android/release/libsimd.so \
    jniLibs/arm64-v8a/libsimd.so
ln -sf <android-library>/target/armv7-linux-androideabi/release/libsimd.so \
    jniLibs/armeabi-v7a/libsimd.so
ln -sf <android-library>/target/i686-linux-android/release/libsimd.so \
    jniLibs/x86/libsimd.so
ln -sf <android-library>/target/x86_64-linux-android/release/libsimd.so \
    jniLibs/x86_64/libsimd.so

The gradle build system won’t check that you used the correct jniLibs/ sub-folders, and the Android system will also happily fallback to a different architecture for devices that support it!

In particular, the original post on Mozilla’s blog used arm64 and armeabi instead of arm64-v8a and armeabi-v7a respectively. This was incorrect because arm64 doesn’t exist as an Android ABI name – it should have been arm64-v8a. Likewise armeabi is a more generic ABI for any ARM CPU than the armv7-linux-androideabi we compiled for with our Rust code – which corresponds to armeabi-v7a.

This means that on the one hand a phone running a 64-bit ARM CPU would fallback to the generic armeabi library, which is sub-optimal for a 64-bit CPU. On the other hand, a phone that supports armeabi but not armeabi-v7a would incorrectly load our Rust library that we compiled for armv7-linux-androideabi. This is probably anecdotal nowadays, but on such old/low-end phones the app could crash if there are any instructions from armeabi-v7a that don’t exist in armeabi (notably the Android ABI docs mention that armeabi-v7a is “incompatible with ARMv5/v6 devices”).

Invoking the Rust library from Java

One remaining question is how to bridge the app’s Java code with our native Rust code. This is done via the Java Native Interface (JNI), which allows to interact with Java objects and functions from native code. This JNI was originally created to bridge with C/C++, but it can likewise be called from Rust.

The easiest for that is to use the jni crate, which conveniently provides wrappers with Rust types around the raw JNI functions. Let’s add this dependency in our Cargo.toml file – note the target_os="android" restriction.

[target.'cfg(target_os="android")'.dependencies]
jni = { version = "0.20", default-features = false }

On the Java side, we’ll need two pieces.

  • Loading the native library, with System.loadLibrary() in a static block (so that the library is loaded before any native function is invoked).
  • Declaring native function(s) that will be implemented in Rust.
package com.example.myrustapplication;

public class NativeLibrary {
    // Load the native library "libsimd.so".
    static {
        System.loadLibrary("simd");
    }

    public String run() {
        return nativeRun();
    }

    // Native function implemented in Rust.
    private static native String nativeRun();
}

Lastly, we need to implement the corresponding function(s) in Rust.

  • Each native function needs to be named Java_<package>_<class>_<function>. We use the #[no_mangle] attribute to make sure that this name appears as is in the .so file, and also need to allow(non_snake_case) given that such a function name doesn’t follow Rust’s preferred style.
  • We use extern "C" so that the function can be correctly invoked by the JNI.
  • The function takes as basic parameters a JNIEnv (the basic object allowing to interact with the JNI), and either a JClass (for a static function like here), or a JObject (referencing this). The function’s parameters follow (here there are none).
  • Lastly, we wrap all the native functions in a block gated by cfg(target_os = "android"). This isn’t mandatory if you’re only compiling for Android, but can be useful if you want to reuse the library on other platforms as well.
#[cfg(target_os = "android")]
#[allow(non_snake_case)]
pub mod android {
    use jni::objects::JClass;
    use jni::sys::jstring;
    use jni::JNIEnv;

    // The native function implemented in Rust.
    #[no_mangle]
    pub unsafe extern "C" fn Java_com_example_myrustapplication_NativeLibrary_nativeRun(
        env: JNIEnv,
        _: JClass,
    ) -> jstring {
        todo!("Implement something useful.")
    }
}

Testing CPU architecture detection from Rust

Now that we have built the Android app, let’s test that our native library is able to detect the CPU architecture. Here is a function to do that – simply returning the architecture string.

fn get_arch_name() -> &'static str {
    #[cfg(target_arch = "x86")]
    return "x86";

    #[cfg(target_arch = "x86_64")]
    return "x86_64";

    #[cfg(target_arch = "arm")]
    return "arm";

    #[cfg(target_arch = "aarch64")]
    return "aarch64";

    #[cfg(not(any(
        target_arch = "x86",
        target_arch = "x86_64",
        target_arch = "arm",
        target_arch = "aarch64",
    )))]
    return "unknown";
}

The next step is to print this string somewhere. We could display it on the application via a TextView widget for example, but if we’ll do a lot of debugging, it would be more convenient to print a message to the console, via Android’s USB debugging. However, Android redirects the standard input/output to /dev/null, so we cannot simply use Rust’s println! to write to the console.

Instead, we can print messages via the android.util.Log class, and use adb logcat to read them into our terminal. For convenience, I’ve written the following wrapper class around it (logger.rs on GitHub).

struct AndroidLogger<'a> {
    /// JNI environment.
    env: JNIEnv<'a>,
    /// Reference to the android.util.Log class.
    log_class: JClass<'a>,
    /// Tag for log messages.
    tag: JString<'a>,
}

impl<'a> AndroidLogger<'a> {
    pub fn new(env: JNIEnv<'a>, tag: &str) -> Result<Self, jni::errors::Error> {
        Ok(Self {
            env,
            log_class: env.find_class("android/util/Log")?,
            tag: env.new_string(tag)?,
        })
    }

    /// Prints a message at the debug level.
    pub fn d(&self, message: impl AsRef<str>) -> Result<(), jni::errors::Error> {
        self.env.call_static_method(
            self.log_class,
            "d",
            "(Ljava/lang/String;Ljava/lang/String;)I",
            &[
                JValue::Object(JObject::from(self.tag)),
                JValue::Object(JObject::from(self.env.new_string(message)?))
            ]
        )?;
        Ok(())
    }
}

This AndroidLogger utility can be used as follows.

let logger = AndroidLogger::new(env, "MyRustSimdApplication")?;
logger.d("Hello Rust world")?;
logger.d(&format!("Your CPU architecture is {}", get_arch_name()))?;

This should give the following output on an Android device with a 64-bit ARM CPU.

D MyRustSimdApplication: Hello Rust world
D MyRustSimdApplication: Your CPU architecture is aarch64

With that, we can test the behavior I mentioned above about what happens when we incorrectly place our Rust library in a folder named arm64 instead of arm64-v8a. I’m also adding the output of the following Build properties, as computed on the Java side of the application.

Log.i(TAG, "SUPPORTED_ABIS: " + Arrays.toString(Build.SUPPORTED_ABIS));
Log.i(TAG, "SUPPORTED_32_BIT_ABIS: " + Arrays.toString(Build.SUPPORTED_32_BIT_ABIS));
Log.i(TAG, "SUPPORTED_64_BIT_ABIS: " + Arrays.toString(Build.SUPPORTED_64_BIT_ABIS));
Log.i(TAG, "CPU_ABI [deprecated]: " + Build.CPU_ABI);
Log.i(TAG, "CPU_ABI2 [deprecated]: " + Build.CPU_ABI2);
Log.i(TAG, "OS.ARCH: " + System.getProperty("os.arch"));

With the correct folder jniLibs/arm64-v8a/, our 64-bit ARM library is loaded.

I MyRustSimdApplication: SUPPORTED_ABIS: [arm64-v8a, armeabi-v7a, armeabi]
I MyRustSimdApplication: SUPPORTED_32_BIT_ABIS: [armeabi-v7a, armeabi]
I MyRustSimdApplication: SUPPORTED_64_BIT_ABIS: [arm64-v8a]
I MyRustSimdApplication: CPU_ABI [deprecated]: arm64-v8a
I MyRustSimdApplication: CPU_ABI2 [deprecated]: 
I MyRustSimdApplication: OS.ARCH: aarch64
D MyRustSimdApplication: Hello Rust world
D MyRustSimdApplication: Your CPU architecture is aarch64

With the incorrect folder jniLibs/arm64/, the 32-bit version of our Rust library is loaded instead (from jniLibs/armeabi/). An interesting aspect is that this also affects the following build properties as observed on the Java side: CPU_ABI, CPU_ABI2 and OS.ARCH!

I MyRustSimdApplication: SUPPORTED_ABIS: [arm64-v8a, armeabi-v7a, armeabi]
I MyRustSimdApplication: SUPPORTED_32_BIT_ABIS: [armeabi-v7a, armeabi]
I MyRustSimdApplication: SUPPORTED_64_BIT_ABIS: [arm64-v8a]
I MyRustSimdApplication: CPU_ABI [deprecated]: armeabi-v7a
I MyRustSimdApplication: CPU_ABI2 [deprecated]: armeabi
I MyRustSimdApplication: OS.ARCH: armv8l
D MyRustSimdApplication: Hello Rust world
D MyRustSimdApplication: Your CPU architecture is arm

Production tips

In this section, I want to mention some important optimizations to reduce the size of your application and get it ready for production.

Optimizing the Rust library size, dividing the APK size by 3

You may think that by compiling your Rust library in --release mode it will already be optimized. However, even in this mode, the compiler includes a lot of debug symbols in the final .so library by default, even though we have no use for them in an Android library. Fortunately, we can generally remove these symbols with the strip tool.

The manual way is to invoke strip on each libsimd.so, as follows.

${NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip \
    <android-library>/target/aarch64-linux-android/release/libsimd.so
${NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip \
    <android-library>/target/armv7-linux-androideabi/release/libsimd.so
${NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip \
    <android-library>/target/i686-linux-android/release/libsimd.so
${NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip \
    <android-library>/target/x86_64-linux-android/release/libsimd.so

A more ergonomic way is to directly use the strip profile option of Rust, which was stabilized last year in Cargo. All you need is adding the following lines to your Cargo.toml file.

[profile.release]
strip = true

I’ve tested both methods, and they gave the same results, so there is no need to use the manual method anymore. Still, don’t forget to enable strip = true in your Cargo.toml, as the size difference can be quite significant!

In principle, Gradle also tries to strip debug symbols of native libraries, but in practice this step fails.

> Task :app:stripDebugDebugSymbols
Unable to strip the following libraries, packaging them as they are: libsimd.so.

> Task :app:stripReleaseDebugSymbols
Unable to strip the following libraries, packaging them as they are: libsimd.so.

For my small example, the .so libraries are 3-4x smaller after stripping.

Raw size of libsimd.so
- aarch64 : 7008552 bytes
- armv7   : 5770196 bytes
- i686    : 6029356 bytes
- x86_64  : 6684280 bytes
...
Stripped size of libsimd.so
- aarch64 : 1873488 bytes
- armv7   : 1338996 bytes
- i686    : 2078292 bytes
- x86_64  : 2005960 bytes

Another interesting statistic is the size of the (un)stripped libraries in the resulting APK. The unzip -lv command gives details about how much space each file takes in a ZIP archive, both in uncompressed and compressed form. In principle, the ZIP archive could apply some compression to the native libraries, but this is disabled by default.

For my example, the sizes with and without stripping are the following (compiled with Rust nightly 1.66.0). I’m also including the Java part for comparison (classes.dex) – in this example you can see that the Rust libraries constitute most of the APK.

Without stripping symbols:

$ unzip -lv /home/dev/build/android-simd-app-release.apk
 Length   Method    Size  Cmpr    Date    Time   CRC-32   Name
--------  ------  ------- ---- ---------- ----- --------  ----
...
  907024  Defl:N   414306  54% 1981-01-01 01:01 910b9b83  classes.dex
 7008552  Stored  7008552   0% 1981-01-01 01:01 89b74bd4  lib/arm64-v8a/libsimd.so
 5770196  Stored  5770196   0% 1981-01-01 01:01 a9dd6549  lib/armeabi-v7a/libsimd.so
 6029356  Stored  6029356   0% 1981-01-01 01:01 c46a2705  lib/x86/libsimd.so
 6684280  Stored  6684280   0% 1981-01-01 01:01 c0d60c4d  lib/x86_64/libsimd.so
...
--------          -------  ---                            -------
27024889         26391227   2%                            399 files

With stripping symbols:

$ unzip -lv /home/dev/build/android-simd-app-release.apk
 Length   Method    Size  Cmpr    Date    Time   CRC-32   Name
--------  ------  ------- ---- ---------- ----- --------  ----
...
  907024  Defl:N   414306  54% 1981-01-01 01:01 910b9b83  classes.dex
 1873488  Stored  1873488   0% 1981-01-01 01:01 014e8eaf  lib/arm64-v8a/libsimd.so
 1338996  Stored  1338996   0% 1981-01-01 01:01 a6fcda7e  lib/armeabi-v7a/libsimd.so
 2078292  Stored  2078292   0% 1981-01-01 01:01 8b5871e8  lib/x86/libsimd.so
 2005960  Stored  2005960   0% 1981-01-01 01:01 f51e7839  lib/x86_64/libsimd.so
...
--------          -------  ---                            -------
 8829241          8195575   7%                            399 files

Without stripping, the Rust libraries represent 96.6% (25.5 MiB) of the final compressed APK (26.4 MiB). After stripping, this goes down to 89.0% (7.3 MiB) of the APK (8.2 MiB). Overall, stripping our Rust libraries made the resulting APK more than 3 times smaller!

For more ideas to reduce the size of your Rust libraries, you can follow the advice from the min-sized-rust repository. In my experience, stripping debug symbols was by far the biggest low-hanging fruit.

Shrinking and testing the release APK

On the Java side, I encourage you to enable Java code shrinking, as documented by Android. In particular, turning on the minifyEnabled parameter in the app/build.gradle configuration made my classes.dex more than 2x smaller!

However, by default gradle compiles two APKs, with different level of optimizations:

  • app/build/outputs/apk/debug/app-debug.apk – ready to use for debugging, but without all these Java optimizations,
  • app/build/outputs/apk/release/app-release-unsigned.apk – with all the optimizations applied, but not signed.

It is particularly important to test the release APK when linking a native library (in Rust or other language), because the shrinking tool may remove Java classes or methods that don’t seem used from Java, even if you actually use them in your Rust code via JNI.

However, as the name goes this APK is not signed, which prevents loading it on a device (or emulator). We’ll therefore need to sign it, even for local testing.

$ adb install /home/dev/build/android-simd-app-release-unsigned.apk 
Performing Streamed Install
adb: failed to install /home/dev/build/android-simd-app-release-unsigned.apk: Failure [INSTALL_PARSE_FAILED_NO_CERTIFICATES: Failed to collect certificates from /data/app/vmdl1326288569.tmp/base.apk: Attempt to get length of null array]

If you intend to distribute your APK on the Play Store, you can already follow the application signing steps with your official signing key, and you should obtain a signed app ready to test locally.

But if you just want to do some quick early testing, you can simply use the debug key (same as the debug APK) without having to generate your own developer key. For that, we can simply use the apksigner tool – that we already have within the Android build tools – and use the debug key from ~/.android/debug.keystore.

${ANDROID_HOME}/build-tools/30.0.3/apksigner \
    sign \
    --in app/build/outputs/apk/release/app-release-unsigned.apk \
    --out app/build/outputs/apk/release/app-release.apk \
    --ks ~/.android/debug.keystore \
    --ks-key-alias androiddebugkey \
    --ks-pass pass:android \
    --key-pass pass:android

Once signed and running on a device, it may happen that your release app crashes, with errors like ClassNotFoundException recorded in logcat. This can happen if you invoke some Java class from Rust only: in this case, because the Java shrinking tool is not analyzing your native Rust code, it will consider that the Java class is dead code and remove it.

--------- beginning of crash
E AndroidRuntime: FATAL EXCEPTION: pool-3-thread-1
E AndroidRuntime: Process: com.example.foo, PID: 12345
E AndroidRuntime: java.lang.Error: java.lang.ClassNotFoundException: com.example.foo.Bar
E AndroidRuntime: 	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:42)
E AndroidRuntime: 	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:42)
E AndroidRuntime: 	at java.lang.Thread.run(Thread.java:42)
E AndroidRuntime: Caused by: java.lang.ClassNotFoundException: com.example.foo.Bar
E AndroidRuntime: 	at com.example.foo.NativeLibrary.nativeRun(Native Method)
E AndroidRuntime: 	at com.example.foo.MainActivity.a(Unknown Source:42)
E AndroidRuntime: 	at com.example.foo.MainActivity$3.run(Unknown Source:42)
E AndroidRuntime: 	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:42)
E AndroidRuntime: 	... 2 more

To overcome these errors, you’ll need to manually customize the code to keep in the app/proguard-rules.pro file. You’ll find more details about the syntax and examples in the ProGuard manual.

# Keep some constructor.
-keep public class com.example.foo.Bar {
    private <init>(long);
}

# Keep all public members.
-keep public class com.example.bar.Foo {
    public *;
}

Another recommended tool to improve your app’s RAM usage is zipalign, but it seems already enabled behind the scenes.

Next up: detecting CPU features

That’s it, we have built step-by-step an Android app that loads Rust code for the correct CPU architecture. You can find the code on GitHub.

Since Mozilla’s blog post from 2017, the Rust ecosystem has boomed on Android. Notably, the android-ndk-rs project (which includes the cargo-apk crate) aims at creating Android applications fully in Rust (disclaimer: I haven’t tested it). On the system side as well, native OS components can be written in Rust.

In the next blog post, we’ll see how to dynamically detect which instructions are supported on a specific CPU. On ARM, we’ll see that this is more complex than it seems, and we’ll even patch the Rust compiler to test that on Android!


Comments

To react to this blog post please check the Twitter thread and the Reddit thread.


RSS | Mastodon | GitHub | Reddit | Twitter


You may also like

Detecting SIMD support on ARM with Android (and patching the Rust compiler for it)
Testing SIMD instructions on ARM with Rust on Android
Making my website 10x smaller in 2024, with a dark mode
Tutorial: Profiling Rust applications in Docker with perf
And 29 more posts on this blog!