Skip to content

Commit c77bcfe

Browse files
authored
Add LlamaDemo Android instrumentation test workflow with model presets (#149)
## Summary - Add GitHub Actions workflow for running LlamaDemo Android instrumentation tests on Ubuntu runners - Support multiple model presets (stories, llama, qwen3, custom) selectable via workflow dispatch - Configure emulator with 16GB RAM to support larger models - Capture and upload logcat as artifact for debugging test failures ## Changes ### Workflow (`.github/workflows/llm-android.yml`) - Runs on `8-core-ubuntu` with KVM acceleration for emulator - Caches AVD snapshots for faster subsequent runs - Model presets selectable via `workflow_dispatch`: - `stories` (default): Small 110M stories model from S3 - `llama`: Llama-3.2-1B from HuggingFace - `qwen3`: Qwen3-4B-INT8-INT4 from HuggingFace - `custom`: User-provided model and tokenizer URLs - Configures emulator with 16GB RAM via AVD config.ini modification - Uploads logcat artifact on both success and failure ### Gradle (`llm/android/LlamaDemo/app/build.gradle.kts`) - Add model preset system with `-PmodelPreset` property - Support custom URLs via `-PcustomPteUrl` and `-PcustomTokenizerUrl` - Gradle automatically downloads and pushes model files to emulator ## Test plan - [x] Run workflow with `stories` preset - verify tests pass - [x] Verify logcat artifact is uploaded - [x] Test with `llama` preset (requires sufficient emulator RAM) - [x] Test with `qwen3` preset
1 parent bdab5b6 commit c77bcfe

3 files changed

Lines changed: 152 additions & 68 deletions

File tree

.github/workflows/llm-android.yml

Lines changed: 58 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -14,38 +14,53 @@ on:
1414
- '.github/workflows/llm-android.yml'
1515
workflow_dispatch:
1616
inputs:
17-
pte_url:
18-
description: 'URL to download model .pte file'
17+
model_preset:
18+
description: 'Model preset to use'
19+
required: true
20+
type: choice
21+
options:
22+
- stories
23+
- llama
24+
- qwen3
25+
- custom
26+
default: 'stories'
27+
custom_pte_url:
28+
description: 'Custom URL for model .pte file (only used when model_preset is custom)'
1929
required: false
2030
type: string
21-
default: 'https://ossci-android.s3.amazonaws.com/executorch/stories/snapshot-20260114/stories110M.pte'
22-
tokenizer_url:
23-
description: 'URL to download tokenizer file'
31+
custom_tokenizer_url:
32+
description: 'Custom URL for tokenizer file (only used when model_preset is custom)'
2433
required: false
2534
type: string
26-
default: 'https://ossci-android.s3.amazonaws.com/executorch/stories/snapshot-20260114/tokenizer.model'
2735

2836
permissions:
2937
contents: read
3038

31-
env:
32-
# Default URLs for pull_request trigger (workflow_dispatch inputs override these)
33-
DEFAULT_PTE_URL: 'https://ossci-android.s3.amazonaws.com/executorch/stories/snapshot-20260114/stories110M.pte'
34-
DEFAULT_TOKENIZER_URL: 'https://ossci-android.s3.amazonaws.com/executorch/stories/snapshot-20260114/tokenizer.model'
35-
3639
jobs:
3740
instrumentation-test:
38-
runs-on: ubuntu-latest
41+
runs-on: 8-core-ubuntu
3942
env:
4043
API_LEVEL: 34
4144
ARCH: x86_64
4245
EMULATOR_OPTIONS: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
46+
RAM_SIZE: 16384
4347

4448
name: Instrumentation Test LlamaDemo
4549
steps:
4650
- name: Checkout repository
4751
uses: actions/checkout@v4
4852

53+
- name: Write job summary
54+
run: |
55+
echo "## Test Configuration" >> $GITHUB_STEP_SUMMARY
56+
echo "| Parameter | Value |" >> $GITHUB_STEP_SUMMARY
57+
echo "|-----------|-------|" >> $GITHUB_STEP_SUMMARY
58+
echo "| Model Preset | \`${{ inputs.model_preset || 'stories' }}\` |" >> $GITHUB_STEP_SUMMARY
59+
if [ "${{ inputs.model_preset }}" = "custom" ]; then
60+
echo "| Custom PTE URL | \`${{ inputs.custom_pte_url }}\` |" >> $GITHUB_STEP_SUMMARY
61+
echo "| Custom Tokenizer URL | \`${{ inputs.custom_tokenizer_url }}\` |" >> $GITHUB_STEP_SUMMARY
62+
fi
63+
4964
- name: Enable KVM group perms
5065
run: |
5166
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
@@ -61,53 +76,63 @@ jobs:
6176
- name: Setup Gradle
6277
uses: gradle/actions/setup-gradle@v4
6378

64-
- name: Download model files
65-
run: |
66-
PTE_URL="${{ inputs.pte_url || env.DEFAULT_PTE_URL }}"
67-
TOKENIZER_URL="${{ inputs.tokenizer_url || env.DEFAULT_TOKENIZER_URL }}"
68-
69-
mkdir -p /tmp/llama-models
70-
echo "Downloading model from $PTE_URL"
71-
curl -fL -o /tmp/llama-models/model.pte "$PTE_URL"
72-
echo "Downloading tokenizer from $TOKENIZER_URL"
73-
curl -fL -o /tmp/llama-models/tokenizer.model "$TOKENIZER_URL"
74-
75-
ls -la /tmp/llama-models/
76-
7779
- name: AVD cache
7880
uses: actions/cache@v4
7981
id: avd-cache
8082
with:
8183
path: |
8284
~/.android/avd/*
8385
~/.android/adb*
84-
key: avd-${{ env.API_LEVEL }}-${{ env.ARCH }}
86+
key: avd-${{ env.API_LEVEL }}-${{ env.ARCH }}-ram${{ env.RAM_SIZE }}
8587

8688
- name: Create AVD and generate snapshot for caching
8789
if: steps.avd-cache.outputs.cache-hit != 'true'
8890
uses: reactivecircus/android-emulator-runner@v2
8991
with:
9092
api-level: ${{ env.API_LEVEL }}
9193
arch: ${{ env.ARCH }}
92-
force-avd-creation: false
93-
ram-size: 6144M
94+
force-avd-creation: true
9495
emulator-options: ${{ env.EMULATOR_OPTIONS }}
9596
disable-animations: false
9697
working-directory: llm/android/LlamaDemo
9798
script: echo "Generated AVD snapshot for caching."
9899

100+
- name: Configure AVD RAM
101+
run: |
102+
AVD_DIR="$HOME/.android/avd"
103+
for config in "$AVD_DIR"/*.avd/config.ini; do
104+
if [ -f "$config" ]; then
105+
echo "Updating RAM in $config"
106+
sed -i 's/hw.ramSize=.*/hw.ramSize=${{ env.RAM_SIZE }}/' "$config" || true
107+
grep -q "hw.ramSize" "$config" || echo "hw.ramSize=${{ env.RAM_SIZE }}" >> "$config"
108+
fi
109+
done
110+
99111
- name: Run instrumentation tests
100112
uses: reactivecircus/android-emulator-runner@v2
113+
env:
114+
MODEL_PRESET: ${{ inputs.model_preset || 'stories' }}
115+
CUSTOM_PTE_URL: ${{ inputs.custom_pte_url }}
116+
CUSTOM_TOKENIZER_URL: ${{ inputs.custom_tokenizer_url }}
101117
with:
102118
api-level: ${{ env.API_LEVEL }}
103119
arch: ${{ env.ARCH }}
104120
force-avd-creation: false
105-
ram-size: 6144M
106121
emulator-options: -no-snapshot-save ${{ env.EMULATOR_OPTIONS }}
107122
disable-animations: true
108123
working-directory: llm/android/LlamaDemo
109124
script: |
110-
adb shell mkdir -p /data/local/tmp/llama/
111-
adb push /tmp/llama-models/model.pte /data/local/tmp/llama/
112-
adb push /tmp/llama-models/tokenizer.model /data/local/tmp/llama/
113-
./gradlew connectedCheck -PskipModelDownload=true
125+
adb shell rm -rf /data/local/tmp/llama
126+
adb shell mkdir -p /data/local/tmp/llama
127+
adb logcat -c && adb logcat > /tmp/logcat.txt &
128+
LOGCAT_PID=$!
129+
if [ "$MODEL_PRESET" = "custom" ]; then GRADLE_ARGS="-PmodelPreset=$MODEL_PRESET -PcustomPteUrl=$CUSTOM_PTE_URL -PcustomTokenizerUrl=$CUSTOM_TOKENIZER_URL"; else GRADLE_ARGS="-PmodelPreset=$MODEL_PRESET"; fi
130+
./gradlew connectedCheck $GRADLE_ARGS; TEST_EXIT_CODE=$?; kill $LOGCAT_PID || true; exit $TEST_EXIT_CODE
131+
132+
- name: Upload logcat
133+
if: always()
134+
uses: actions/upload-artifact@v4
135+
with:
136+
name: logcat
137+
path: /tmp/logcat.txt
138+
retention-days: 7

llm/android/LlamaDemo/app/build.gradle.kts

Lines changed: 91 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,36 @@ plugins {
1212
}
1313

1414
// Model files configuration for instrumentation tests
15-
val modelFilesBaseUrl = "https://ossci-android.s3.amazonaws.com/executorch/stories/snapshot-20260114"
16-
val deviceModelDir = "/data/local/tmp/llama"
17-
val modelFiles = mapOf(
18-
"stories110M.pte" to "model.pte",
19-
"tokenizer.model" to "tokenizer.model"
15+
// Supported presets: stories, llama, custom
16+
val modelPreset: String = (project.findProperty("modelPreset") as? String) ?: "stories"
17+
18+
// Preset configurations
19+
val modelPresets = mapOf(
20+
"stories" to mapOf(
21+
"baseUrl" to "https://ossci-android.s3.amazonaws.com/executorch/stories/snapshot-20260114",
22+
"pteFile" to "stories110M.pte",
23+
"tokenizerFile" to "tokenizer.model",
24+
"verifyChecksum" to true
25+
),
26+
"llama" to mapOf(
27+
"baseUrl" to "https://huggingface.co/executorch-community/Llama-3.2-1B-ET/resolve/main",
28+
"pteFile" to "llama3_2-1B.pte",
29+
"tokenizerFile" to "tokenizer.model",
30+
"verifyChecksum" to false
31+
),
32+
"qwen3" to mapOf(
33+
"baseUrl" to "https://huggingface.co/pytorch/Qwen3-4B-INT8-INT4/resolve/main",
34+
"pteFile" to "model.pte",
35+
"tokenizerFile" to "tokenizer.json",
36+
"verifyChecksum" to false
37+
)
2038
)
39+
40+
// Custom URLs (used when modelPreset is "custom")
41+
val customPteUrl: String? = project.findProperty("customPteUrl") as? String
42+
val customTokenizerUrl: String? = project.findProperty("customTokenizerUrl") as? String
43+
44+
val deviceModelDir = "/data/local/tmp/llama"
2145
val skipModelDownload: Boolean = (project.findProperty("skipModelDownload") as? String)?.toBoolean() ?: false
2246

2347
fun execCmd(vararg args: String): String {
@@ -39,7 +63,7 @@ fun execCmdWithExitCode(vararg args: String): Pair<Int, String> {
3963
}
4064

4165
tasks.register("pushModelFiles") {
42-
description = "Download model files from S3 and push to connected Android device if not present"
66+
description = "Download model files and push to connected Android device if not present"
4367
group = "verification"
4468

4569
doLast {
@@ -48,6 +72,31 @@ tasks.register("pushModelFiles") {
4872
return@doLast
4973
}
5074

75+
logger.lifecycle("Using model preset: $modelPreset")
76+
77+
// Determine URLs based on preset
78+
val pteUrl: String
79+
val tokenizerUrl: String
80+
val verifyChecksum: Boolean
81+
82+
if (modelPreset == "custom") {
83+
pteUrl = customPteUrl ?: throw GradleException("customPteUrl is required when modelPreset is 'custom'")
84+
tokenizerUrl = customTokenizerUrl ?: throw GradleException("customTokenizerUrl is required when modelPreset is 'custom'")
85+
verifyChecksum = false
86+
} else {
87+
val preset = modelPresets[modelPreset] ?: throw GradleException("Unknown model preset: $modelPreset. Valid options: stories, llama, custom")
88+
val baseUrl = preset["baseUrl"] as String
89+
pteUrl = "$baseUrl/${preset["pteFile"]}"
90+
tokenizerUrl = "$baseUrl/${preset["tokenizerFile"]}"
91+
verifyChecksum = preset["verifyChecksum"] as Boolean
92+
}
93+
94+
// Files to download: source URL -> target name on device
95+
val filesToDownload = mapOf(
96+
pteUrl to "model.pte",
97+
tokenizerUrl to "tokenizer.model"
98+
)
99+
51100
// Check if adb is available
52101
val adbPath = android.adbExecutable.absolutePath
53102
val (adbCheckCode, _) = execCmdWithExitCode(adbPath, "devices")
@@ -56,7 +105,7 @@ tasks.register("pushModelFiles") {
56105
}
57106

58107
// Check which files need to be pushed
59-
val filesToPush = modelFiles.filter { (_, targetName) ->
108+
val filesToPush = filesToDownload.filter { (_, targetName) ->
60109
val devicePath = "$deviceModelDir/$targetName"
61110
val (exitCode, _) = execCmdWithExitCode(adbPath, "shell", "test -f $devicePath && echo exists")
62111
exitCode != 0
@@ -77,43 +126,51 @@ tasks.register("pushModelFiles") {
77126
// Create device directory
78127
execCmd(adbPath, "shell", "mkdir -p $deviceModelDir")
79128

80-
for ((sourceName, targetName) in filesToPush) {
129+
for ((sourceUrl, targetName) in filesToPush) {
81130
val localPath = "$tempDir/$targetName"
82-
val checksumPath = "$tempDir/$sourceName.sha256sums"
83131
val devicePath = "$deviceModelDir/$targetName"
84132

85-
// Download file (with original name for checksum verification, then rename)
86-
val downloadPath = "$tempDir/$sourceName"
87-
logger.lifecycle("Downloading $sourceName...")
133+
// Download file
134+
logger.lifecycle("Downloading from $sourceUrl...")
88135
val (dlCode, dlOutput) = execCmdWithExitCode(
89-
"curl", "-fL", "-o", downloadPath, "$modelFilesBaseUrl/$sourceName"
136+
"curl", "-fL", "-o", localPath, sourceUrl
90137
)
91138
if (dlCode != 0) {
92-
throw GradleException("Failed to download $sourceName: $dlOutput")
139+
throw GradleException("Failed to download from $sourceUrl: $dlOutput")
93140
}
94141

95-
// Download and verify checksum
96-
logger.lifecycle("Verifying checksum for $sourceName...")
97-
val (csDownloadCode, csDownloadOutput) = execCmdWithExitCode(
98-
"curl", "-fL", "-o", checksumPath, "$modelFilesBaseUrl/$sourceName.sha256sums"
99-
)
100-
if (csDownloadCode != 0) {
101-
throw GradleException("Failed to download checksum for $sourceName: $csDownloadOutput")
102-
}
142+
// Verify checksum if enabled and available (only for stories preset)
143+
if (verifyChecksum && modelPreset == "stories") {
144+
val sourceName = sourceUrl.substringAfterLast("/")
145+
val checksumPath = "$tempDir/$sourceName.sha256sums"
146+
val checksumUrl = "$sourceUrl.sha256sums"
103147

104-
// Verify checksum (run sha256sum in the temp directory)
105-
val (verifyCode, verifyOutput) = execCmdWithExitCode(
106-
"bash", "-c", "cd $tempDir && sha256sum -c $sourceName.sha256sums"
107-
)
108-
if (verifyCode != 0) {
109-
throw GradleException("Checksum verification failed for $sourceName: $verifyOutput")
110-
}
111-
logger.lifecycle("Checksum verified for $sourceName")
148+
logger.lifecycle("Verifying checksum for $sourceName...")
149+
val (csDownloadCode, _) = execCmdWithExitCode(
150+
"curl", "-fL", "-o", checksumPath, checksumUrl
151+
)
152+
if (csDownloadCode == 0) {
153+
// Copy file to original name for checksum verification if needed
154+
val tempForChecksum = "$tempDir/$sourceName"
155+
val needsCopy = localPath != tempForChecksum
156+
if (needsCopy) {
157+
execCmd("cp", localPath, tempForChecksum)
158+
}
112159

113-
// Rename file if needed
114-
if (sourceName != targetName) {
115-
execCmd("mv", downloadPath, localPath)
116-
logger.lifecycle("Renamed $sourceName to $targetName")
160+
val (verifyCode, verifyOutput) = execCmdWithExitCode(
161+
"bash", "-c", "cd $tempDir && sha256sum -c $sourceName.sha256sums"
162+
)
163+
if (verifyCode != 0) {
164+
throw GradleException("Checksum verification failed for $sourceName: $verifyOutput")
165+
}
166+
logger.lifecycle("Checksum verified for $sourceName")
167+
// Only delete the temp copy if we made one
168+
if (needsCopy) {
169+
execCmd("rm", "-f", tempForChecksum)
170+
}
171+
} else {
172+
logger.lifecycle("Checksum file not available, skipping verification")
173+
}
117174
}
118175

119176
// Push to device

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@ public String getSettings() {
6262
public void saveLogs() {
6363
SharedPreferences.Editor editor = sharedPreferences.edit();
6464
Gson gson = new Gson();
65-
String msgJSON = gson.toJson(ETLogging.getInstance().getLogs());
65+
// Create a copy to avoid ConcurrentModificationException if logs are added during serialization
66+
ArrayList<AppLog> logsCopy = new ArrayList<>(ETLogging.getInstance().getLogs());
67+
String msgJSON = gson.toJson(logsCopy);
6668
editor.putString(context.getString(R.string.logs_json_key), msgJSON);
6769
editor.apply();
6870
}

0 commit comments

Comments
 (0)