Skip to content

Commit 5eb4773

Browse files
committed
Merge remote-tracking branch 'origin/main' into add-preset-stories
2 parents c34ddb8 + 15d3074 commit 5eb4773

22 files changed

Lines changed: 1675 additions & 29 deletions

File tree

.github/workflows/android-build.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ jobs:
3131
path: llm/android/LlamaDemo
3232
- name: DeepLabV3Demo
3333
path: dl3/android/DeepLabV3Demo
34+
- name: MV3Demo
35+
path: mv3/android/MV3Demo
36+
3437

3538
name: Build ${{ matrix.name }}
3639
steps:
@@ -108,6 +111,7 @@ jobs:
108111
## Apps included:
109112
- LlamaDemo APKs (`app-debug-LlamaDemo.apk`, `app-release-unsigned-LlamaDemo.apk`)
110113
- DeepLabV3Demo APKs (`app-debug-DeepLabV3Demo.apk`, `app-release-unsigned-DeepLabV3Demo.apk`)
114+
- MV3Demo APKs (`app-debug-MV3Demo.apk`, `app-release-unsigned-MV3Demo.apk`)
111115
112116
**Build Date:** ${{ steps.tag.outputs.build_date }}
113117
**Commit:** ${{ github.sha }}

.github/workflows/llm-android.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ on:
3232
description: 'Custom URL for tokenizer file (only used when model_preset is custom)'
3333
required: false
3434
type: string
35+
local_aar:
36+
description: 'URL to download a local AAR file. When set, the workflow will download the AAR and use it instead of the Maven dependency.'
37+
required: false
38+
type: string
3539

3640
permissions:
3741
contents: read
@@ -74,6 +78,12 @@ jobs:
7478
- name: Setup Gradle
7579
uses: gradle/actions/setup-gradle@v4
7680

81+
- name: Download local AAR
82+
if: ${{ inputs.local_aar }}
83+
run: |
84+
mkdir -p llm/android/LlamaDemo/app/libs
85+
curl -fL -o llm/android/LlamaDemo/app/libs/executorch.aar "${{ inputs.local_aar }}"
86+
7787
- name: AVD cache
7888
uses: actions/cache@v4
7989
id: avd-cache
@@ -145,6 +155,7 @@ jobs:
145155
uses: reactivecircus/android-emulator-runner@v2
146156
env:
147157
MODEL_PRESET: ${{ inputs.model_preset || 'stories' }}
158+
USE_LOCAL_AAR: ${{ inputs.local_aar != '' }}
148159
with:
149160
api-level: ${{ env.API_LEVEL }}
150161
arch: ${{ env.ARCH }}
@@ -154,7 +165,7 @@ jobs:
154165
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -no-snapshot-save -memory 16384
155166
disable-animations: true
156167
working-directory: llm/android/LlamaDemo
157-
script: bash ./scripts/run-ci-tests.sh "$MODEL_PRESET" "$MODEL_FILE" "$TOKENIZER_FILE"
168+
script: bash ./scripts/run-ci-tests.sh "$MODEL_PRESET" "$MODEL_FILE" "$TOKENIZER_FILE" "$USE_LOCAL_AAR"
158169

159170
- name: Add model response to summary
160171
if: always()

llm/android/LlamaDemo/app/src/androidTest/java/com/example/executorchllamademo/PresetSanityTest.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
2525
import androidx.test.filters.LargeTest
2626
import org.junit.Assert.assertTrue
2727
import org.junit.Before
28+
import org.junit.Ignore
2829
import org.junit.Rule
2930
import org.junit.Test
3031
import org.junit.runner.RunWith
@@ -165,6 +166,7 @@ class PresetSanityTest {
165166
* 5. Type "Once upon a time" and send
166167
* 6. Verify response is generated
167168
*/
169+
@Ignore("Temporarily disabled")
168170
@Test
169171
fun testPresetModelDownloadAndChat() {
170172
composeTestRule.waitForIdle()

llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/PromptFormat.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,12 @@ object PromptFormat {
8585
@JvmStatic
8686
fun getLlavaPresetPrompt(): String {
8787
return "A chat between a curious human and an artificial intelligence assistant. The assistant" +
88-
" gives helpful, detailed, and polite answers to the human's questions. USER: "
88+
" gives helpful, detailed, and polite answers to the human's questions."
8989
}
9090

9191
@JvmStatic
92-
fun getLlavaFirstTurnUserPrompt(): String {
93-
return "$USER_PLACEHOLDER ASSISTANT:"
92+
fun getLlavaMultimodalUserPrompt(): String {
93+
return "USER: $USER_PLACEHOLDER ASSISTANT:"
9494
}
9595

9696
@JvmStatic

llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ChatViewModel.kt

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -110,23 +110,26 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L
110110
val isLoadModel = updatedSettingsFields.isLoadModel
111111
if (isUpdated) {
112112
checkForClearChatHistory(updatedSettingsFields)
113-
// Update media capabilities after settings are updated
114-
setBackendMode(updatedSettingsFields.backendType)
115113

116114
if (isLoadModel) {
115+
// Update local copy BEFORE checking media capabilities
116+
val settingsWithLoadFlagCleared = updatedSettingsFields.copy(isLoadModel = false)
117+
currentSettingsFields = settingsWithLoadFlagCleared
118+
demoSharedPreferences.saveModuleSettings(settingsWithLoadFlagCleared)
119+
120+
// Update media capabilities after settings are updated
121+
setBackendMode(updatedSettingsFields.backendType)
122+
117123
loadLocalModelAndParameters(
118124
updatedSettingsFields.modelFilePath,
119125
updatedSettingsFields.tokenizerFilePath,
120126
updatedSettingsFields.dataPath,
121127
updatedSettingsFields.temperature.toFloat()
122128
)
123-
// Save with isLoadModel = false and update local copy to match,
124-
// preventing duplicate "To get started..." messages on subsequent calls
125-
val settingsWithLoadFlagCleared = updatedSettingsFields.copy(isLoadModel = false)
126-
demoSharedPreferences.saveModuleSettings(settingsWithLoadFlagCleared)
127-
currentSettingsFields = settingsWithLoadFlagCleared
128129
} else {
129130
currentSettingsFields = updatedSettingsFields.copy()
131+
// Update media capabilities after settings are updated
132+
setBackendMode(updatedSettingsFields.backendType)
130133
if (module == null) {
131134
addSystemMessage(systemPromptMessage)
132135
}
@@ -243,8 +246,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L
243246
modelInfo = "Successfully loaded model. $pteName and tokenizer $tokenizerName in ${loadDuration.toFloat() / 1000} sec. $capabilityText"
244247

245248
if (currentSettingsFields.modelType == ModelType.LLAVA_1_5) {
246-
ETLogging.getInstance().log("Llava start prefill prompt")
247-
module?.prefillPrompt(PromptFormat.getLlavaPresetPrompt())
249+
val llavaPresetPrompt = PromptFormat.getLlavaPresetPrompt()
250+
ETLogging.getInstance().log("Llava start prefill prompt: $llavaPresetPrompt")
251+
module?.prefillPrompt(llavaPresetPrompt)
248252
ETLogging.getInstance().log("Llava completes prefill prompt")
249253
}
250254
loadSuccess = true
@@ -318,13 +322,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L
318322
val processedImageList = getProcessedImagesForModel(_selectedImages)
319323
if (processedImageList.isNotEmpty()) {
320324
_messages.add(
321-
Message("Llava/Gemma - Starting image Prefill.", false, MessageType.SYSTEM, 0)
325+
Message("Starting image prefill.", false, MessageType.SYSTEM, 0)
322326
)
323327
executor.execute {
324328
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_MORE_FAVORABLE)
325329
ETLogging.getInstance().log("Starting runnable prefill image")
326330
val img = processedImageList[0]
327-
ETLogging.getInstance().log("Start prefill image")
331+
ETLogging.getInstance().log("Starting prefill image")
328332
if (currentSettingsFields.modelType == ModelType.LLAVA_1_5) {
329333
module?.prefillImages(
330334
img.getInts(),
@@ -333,7 +337,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L
333337
ModelUtils.VISION_MODEL_IMAGE_CHANNELS
334338
)
335339
} else if (currentSettingsFields.modelType == ModelType.GEMMA_3) {
336-
module?.prefillPrompt(PromptFormat.getGemmaPreImagePrompt())
340+
val gemmaPreImagePrompt = PromptFormat.getGemmaPreImagePrompt()
341+
ETLogging.getInstance().log("Gemma prefill pre-image prompt: $gemmaPreImagePrompt")
342+
module?.prefillPrompt(gemmaPreImagePrompt)
337343
module?.prefillImages(
338344
img.getFloats(),
339345
img.width,
@@ -375,8 +381,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L
375381
val rawPrompt = inputText
376382
val finalPrompt: String
377383

378-
if (currentSettingsFields.modelType == ModelType.LLAVA_1_5 && shouldAddSystemPrompt) {
379-
finalPrompt = PromptFormat.getLlavaFirstTurnUserPrompt()
384+
if (currentSettingsFields.modelType == ModelType.LLAVA_1_5 && _selectedImages.isNotEmpty()) {
385+
finalPrompt = PromptFormat.getLlavaMultimodalUserPrompt()
386+
.replace(PromptFormat.USER_PLACEHOLDER, rawPrompt)
387+
} else if (currentSettingsFields.modelType == ModelType.GEMMA_3 && _selectedImages.isNotEmpty()) {
388+
finalPrompt = PromptFormat.getGemmaMultimodalUserPrompt()
380389
.replace(PromptFormat.USER_PLACEHOLDER, rawPrompt)
381390
} else if (currentSettingsFields.modelType == ModelType.GEMMA_3 && _selectedImages.isNotEmpty()) {
382391
finalPrompt = PromptFormat.getGemmaMultimodalUserPrompt()
@@ -415,8 +424,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L
415424
if (currentSettingsFields.modelType == ModelType.VOXTRAL && audioFileToPrefill != null) {
416425
prefillVoxtralAudio(audioFileToPrefill!!, finalPrompt)
417426
audioFileToPrefill = null
427+
ETLogging.getInstance().log("Running vision model inference.. prompt=(empty after audio prefill)")
418428
module?.generate("", ModelUtils.VISION_MODEL_SEQ_LEN, this, false)
419429
} else {
430+
ETLogging.getInstance().log("Running vision model inference.. prompt=$finalPrompt")
420431
module?.generate(finalPrompt, ModelUtils.VISION_MODEL_SEQ_LEN, this, false)
421432
}
422433
} else if (currentSettingsFields.modelType == ModelType.LLAMA_GUARD_3) {
@@ -459,9 +470,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L
459470
val bins = 128
460471
val frames = 3000
461472
val batchSize = floatCount / (bins * frames)
462-
module?.prefillPrompt("<s>[INST][BEGIN_AUDIO]")
473+
val preAudioPrompt = "<s>[INST][BEGIN_AUDIO]"
474+
val postAudioPrompt = "$textPrompt[/INST]"
475+
ETLogging.getInstance().log("Voxtral prefill pre-audio prompt: $preAudioPrompt")
476+
module?.prefillPrompt(preAudioPrompt)
463477
module?.prefillAudio(floats, batchSize, bins, frames)
464-
module?.prefillPrompt("$textPrompt[/INST]")
478+
ETLogging.getInstance().log("Voxtral prefill post-audio prompt: $postAudioPrompt")
479+
module?.prefillPrompt(postAudioPrompt)
465480
} catch (e: IOException) {
466481
Log.e("AudioPrefill", "Audio file error")
467482
}

llm/android/LlamaDemo/docs/delegates/qualcomm_README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,12 +106,12 @@ We support PTQ by default. The entire export may take ~20 minutes (Llama 3.1 8B)
106106
Examples:
107107
```
108108
# 4 bits weight only quantize
109-
python -m extension.llm.export.export_llm base.checkpoint="${MODEL_DIR}/consolidated.00.pth" base.params="${MODEL_DIR}/params.json" model.use_kv_cache=True model.enable_dynamic_shape=False backend.qnn.enabled=True backend.qnn.quantization="qnn_16a4w" model.dtype_override="fp32" base.metadata='"{\"get_bos_id\":128000, \"get_eos_ids\":[128009, 128001]}"' export.output_name="test.pte"
109+
python -m extension.llm.export.export_llm base.checkpoint="${MODEL_DIR}/consolidated.00.pth" base.params="${MODEL_DIR}/params.json" model.use_kv_cache=True model.enable_dynamic_shape=False backend.qnn.enabled=True quantization.pt2e_quantize="qnn_16a4w" model.dtype_override="fp32" base.metadata='"{\"get_bos_id\":128000, \"get_eos_ids\":[128009, 128001]}"' export.output_name="test.pte"
110110
```
111111
If the model is really big, it may require model sharding because the Qualcomm DSP is a 32bit system and has a 4GB size limit . For example for Llama 3 8B models, we need to shard the model into 4, but ExecuTorch still packages it into one PTE file. Here is an example:
112112
```
113113
# 8 bits quantization with 4 shards
114-
python -m extension.llm.export.export_llm base.checkpoint="${MODEL_DIR}/consolidated.00.pth" base.params="${MODEL_DIR}/params.json" model.use_kv_cache=True model.enable_dynamic_shape=False backend.qnn.enabled=True backend.qnn.quantization="qnn_8a8w" model.dtype_override="fp32" backend.qnn.num_sharding=4 base.metadata='"{\"get_bos_id\":128000, \"get_eos_ids\":[128009, 128001]}"' export.output_name="test.pte"
114+
python -m extension.llm.export.export_llm base.checkpoint="${MODEL_DIR}/consolidated.00.pth" base.params="${MODEL_DIR}/params.json" model.use_kv_cache=True model.enable_dynamic_shape=False backend.qnn.enabled=True quantization.pt2e_quantize="qnn_8a8w" model.dtype_override="fp32" backend.qnn.num_sharding=4 base.metadata='"{\"get_bos_id\":128000, \"get_eos_ids\":[128009, 128001]}"' export.output_name="test.pte"
115115
```
116116
Note: if you encountered issues below
117117
```
@@ -163,7 +163,7 @@ To export Llama 3 8B instruct with the Qualcomm AI Engine Direct Backend, ensure
163163
* 8B models might need 16GB RAM on the device to run.
164164
```
165165
# Please note that calibration_data must include the prompt template for special tokens.
166-
python -m extension.llm.export.export_llm base.tokenizer=<path_to_tokenizer.model> base.params=<path_to_params.json> base.checkpoint=<path_to_checkpoint_for_Meta-Llama-3-8B-Instruct> model.use_kv_cache=True backend.qnn.enabled=True backend.qnn.quantization="qnn_16a4w" model.enable_dynamic_shape=False backend.qnn.num_sharding=8 backend.qnn.calibration_tasks="wikitext" backend.qnn.calibration_limit=1 backend.qnn.calibration_seq_length=128 backend.qnn.optimized_rotation_path=<path_to_optimized_matrix> backend.qnn.calibration_data="<|start_header_id|>system<|end_header_id|>\n\nYou are a funny chatbot.<|eot_id|><|start_header_id|>user<|end_header_id|>\n\nCould you tell me about Facebook?<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n\n"
166+
python -m extension.llm.export.export_llm base.tokenizer=<path_to_tokenizer.model> base.params=<path_to_params.json> base.checkpoint=<path_to_checkpoint_for_Meta-Llama-3-8B-Instruct> model.use_kv_cache=True backend.qnn.enabled=True quantization.pt2e_quantize="qnn_16a4w" model.enable_dynamic_shape=False backend.qnn.num_sharding=8 backend.qnn.calibration_tasks="wikitext" backend.qnn.calibration_limit=1 backend.qnn.calibration_seq_length=128 backend.qnn.optimized_rotation_path=<path_to_optimized_matrix> backend.qnn.calibration_data="<|start_header_id|>system<|end_header_id|>\n\nYou are a funny chatbot.<|eot_id|><|start_header_id|>user<|end_header_id|>\n\nCould you tell me about Facebook?<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n\n"
167167
```
168168

169169
## Pushing Model and Tokenizer

llm/android/LlamaDemo/scripts/run-ci-tests.sh

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
# LICENSE file in the root directory of this source tree.
77

88
# CI test script for running instrumentation tests with pre-downloaded models
9-
# Usage: ./run-ci-tests.sh <model_preset> <model_file> <tokenizer_file>
9+
# Usage: ./run-ci-tests.sh <model_preset> <model_file> <tokenizer_file> [use_local_aar]
1010
#
1111
# This script is designed for CI environments where models are pre-downloaded
1212
# to /tmp/llama_models/ before the emulator starts.
@@ -16,11 +16,13 @@ set -ex
1616
MODEL_PRESET="$1"
1717
MODEL_FILE="$2"
1818
TOKENIZER_FILE="$3"
19+
USE_LOCAL_AAR="${4:-false}"
1920

2021
echo "=== Test Configuration ==="
2122
echo "MODEL_PRESET: $MODEL_PRESET"
2223
echo "MODEL_FILE: $MODEL_FILE"
2324
echo "TOKENIZER_FILE: $TOKENIZER_FILE"
25+
echo "USE_LOCAL_AAR: $USE_LOCAL_AAR"
2426

2527
echo "=== Emulator Memory Info ==="
2628
adb shell cat /proc/meminfo | head -5
@@ -90,9 +92,11 @@ adb logcat > /tmp/logcat.txt &
9092
LOGCAT_PID=$!
9193

9294
echo "=== Starting Gradle ==="
93-
./gradlew connectedCheck \
94-
-PskipModelDownload=true \
95-
-PmodelPreset="$MODEL_PRESET"
95+
GRADLE_ARGS="-PskipModelDownload=true -PmodelPreset=\"$MODEL_PRESET\""
96+
if [ "$USE_LOCAL_AAR" = "true" ]; then
97+
GRADLE_ARGS="$GRADLE_ARGS -PuseLocalAar=true"
98+
fi
99+
eval ./gradlew connectedCheck "$GRADLE_ARGS"
96100
TEST_EXIT_CODE=$?
97101

98102
echo "=== Model directory after Gradle ==="

llm/apple/etLLM.xcodeproj/project.pbxproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,7 @@
462462
isa = XCRemoteSwiftPackageReference;
463463
repositoryURL = "https://github.com/pytorch/executorch";
464464
requirement = {
465-
branch = "swiftpm-1.0.0";
465+
branch = "swiftpm-1.1.0";
466466
kind = branch;
467467
};
468468
};

mv3/android/MV3Demo/README.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# MV3 Android Demo
2+
3+
This is a sample Android application demonstrating MobileNet v3 (MV3) image classification using PyTorch ExecuTorch.
4+
5+
## Features
6+
7+
- **MobileNet v3 Inference**: Runs the MV3 model on Android.
8+
- **Live Camera Feed**: Real-time classification on camera frames.
9+
- **Image Selection**: Pick images from the gallery for classification.
10+
- **Material Design 3**: Modern UI with a bottom app bar and intuitive controls.
11+
12+
## Prerequisites
13+
14+
- Android SDK (API 34+)
15+
- JDK 17
16+
- ExecuTorch libraries (configured via Gradle)
17+
18+
## Setup
19+
20+
1. **Clone the repository**:
21+
```bash
22+
git clone https://github.com/meta-pytorch/executorch-examples.git
23+
cd executorch-examples/mv3/android/MV3Demo
24+
```
25+
26+
2. **Build the project**:
27+
```bash
28+
./gradlew :app:assembleDebug
29+
```
30+
31+
3. **Install on device**:
32+
```bash
33+
./gradlew :app:installDebug
34+
```
35+
36+
## Usage
37+
38+
- **Local Model**: The app attempts to load `mv3.pte` from the app's internal storage. If missing, it offers a download button (currently configured with a placeholder URL).
39+
- **Live Camera**: Grant camera permissions to use the real-time classification feature.
40+
- **Pick Image**: Select an image from your device to classify it.
41+
42+
## Architecture
43+
44+
- **UI**: Jetpack Compose
45+
- **Camera**: CameraX (Preview + ImageAnalysis)
46+
- **Inference**: ExecuTorch Android API
47+
- **Image Processing**: `TensorImageUtils` for bitmap-to-tensor conversion
48+
49+
## Testing
50+
51+
The app includes an instrumentation test that validates the complete image classification workflow.
52+
53+
### What the test does
54+
55+
1. Launches the app
56+
2. Downloads the MV3 model if not already present
57+
3. Downloads a cat image from HuggingFace
58+
4. Runs inference on the image
59+
5. Validates that the model correctly classifies it as a cat
60+
61+
### Running the test
62+
63+
1. **Connect a device or start an emulator**
64+
65+
2. **Build and install the test APKs**:
66+
```bash
67+
./gradlew installDebug installDebugAndroidTest
68+
```
69+
70+
3. **Run the test**:
71+
```bash
72+
adb shell am instrument -w -r \
73+
-e class 'org.pytorch.executorchexamples.mv3.UIWorkflowTest#testCatImageClassification' \
74+
org.pytorch.executorchexamples.mv3.test/androidx.test.runner.AndroidJUnitRunner
75+
```
76+
77+
Or run all tests via Gradle:
78+
```bash
79+
./gradlew connectedDebugAndroidTest
80+
```
81+
82+
### Test output
83+
84+
The test logs classification results to logcat with the tag `MV3_RESULT`:
85+
```bash
86+
adb logcat -s MV3_RESULT
87+
```
88+
89+
## License
90+
91+
BSD-3-Clause

0 commit comments

Comments
 (0)