Skip to content

Commit 0aab53c

Browse files
authored
Improved the performance of Base64 encoding/decoding on iOS and Android
This includes a new API that lets developers reuse a buffer to reduce gc thrashing but even without this API the performance improvement is significant On Android Base64 encode was 7.8% slower and decode was 112.3% slower. After these improvements encode is 73.4% faster and decode is 61.3% faster! For iOS encode was 245.1% slower and decode was 319.6% slower. Encode is now 39.7% slower and decode is 98.9% slower. Base64 is a highly optimized native function in iOS that probably makes heavy use of SIMD device capabilities. To get this to parity with native we will probably need to support SIMD APIs in ParparVM.
1 parent d6acf9e commit 0aab53c

15 files changed

Lines changed: 1091 additions & 138 deletions

File tree

CodenameOne/src/com/codename1/util/Base64.java

Lines changed: 269 additions & 86 deletions
Large diffs are not rendered by default.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.codenameone.examples.hellocodenameone;
2+
3+
import android.util.Base64;
4+
5+
import java.nio.charset.StandardCharsets;
6+
7+
public class Base64NativeImpl {
8+
public String encodeUtf8(String plainText) {
9+
if (plainText == null) {
10+
return null;
11+
}
12+
byte[] data = plainText.getBytes(StandardCharsets.UTF_8);
13+
return Base64.encodeToString(data, Base64.NO_WRAP);
14+
}
15+
16+
public String decodeToUtf8(String base64Text) {
17+
if (base64Text == null) {
18+
return null;
19+
}
20+
byte[] data = Base64.decode(base64Text, Base64.DEFAULT);
21+
return new String(data, StandardCharsets.UTF_8);
22+
}
23+
24+
public boolean isSupported() {
25+
return true;
26+
}
27+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.codenameone.examples.hellocodenameone;
2+
3+
import com.codename1.system.NativeInterface;
4+
5+
public interface Base64Native extends NativeInterface {
6+
String encodeUtf8(String plainText);
7+
String decodeToUtf8(String base64Text);
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
package com.codenameone.examples.hellocodenameone.tests;
2+
3+
import com.codename1.system.NativeLookup;
4+
import com.codename1.ui.Display;
5+
import com.codenameone.examples.hellocodenameone.Base64Native;
6+
import com.codename1.util.Base64;
7+
8+
9+
public class Base64NativePerformanceTest extends BaseTest {
10+
private static final int PAYLOAD_BYTES = 8192;
11+
private static final int ITERATIONS = 6000;
12+
13+
@Override
14+
public boolean shouldTakeScreenshot() {
15+
return false;
16+
}
17+
18+
@Override
19+
public boolean runTest() {
20+
Base64Native nativeBase64 = NativeLookup.create(Base64Native.class);
21+
if (nativeBase64 == null || !nativeBase64.isSupported()) {
22+
System.out.println("CN1SS:STAT:Base64 benchmark status: skipped (native base64 bridge unavailable)");
23+
done();
24+
return true;
25+
}
26+
27+
String payload = buildPayload();
28+
String nativeEncoded = nativeBase64.encodeUtf8(payload);
29+
if (nativeEncoded == null || nativeEncoded.length() == 0) {
30+
fail("Native Base64 encode returned empty result");
31+
return false;
32+
}
33+
34+
byte[] payloadBytes;
35+
try {
36+
payloadBytes = payload.getBytes("UTF-8");
37+
} catch (Exception ex) {
38+
fail("Failed to encode payload to UTF-8: " + ex);
39+
return false;
40+
}
41+
42+
String cn1Encoded = Base64.encodeNoNewline(payloadBytes);
43+
String nativeDecoded = nativeBase64.decodeToUtf8(nativeEncoded);
44+
if (!payload.equals(nativeDecoded)) {
45+
fail("Native Base64 decode mismatch");
46+
return false;
47+
}
48+
49+
String cn1Decoded = decodeUtf8(cn1Encoded);
50+
if (!payload.equals(cn1Decoded)) {
51+
fail("CN1 Base64 decode mismatch");
52+
return false;
53+
}
54+
55+
int encodedLen = ((payloadBytes.length + 2) / 3) * 4;
56+
byte[] cn1EncodedBytes = new byte[encodedLen];
57+
int encodedWritten = Base64.encodeNoNewline(payloadBytes, cn1EncodedBytes);
58+
if (encodedWritten != encodedLen) {
59+
fail("CN1 preallocated Base64 encode returned unexpected length");
60+
return false;
61+
}
62+
byte[] cn1DecodedBuffer = new byte[payloadBytes.length];
63+
64+
if (!isIos()) {
65+
warmup(nativeBase64, payload, payloadBytes, nativeEncoded, cn1EncodedBytes, cn1DecodedBuffer);
66+
}
67+
68+
long nativeEncodeMs = measureNativeEncode(nativeBase64, payload);
69+
long cn1EncodeMs = measureCn1Encode(payloadBytes, cn1EncodedBytes);
70+
long nativeDecodeMs = measureNativeDecode(nativeBase64, nativeEncoded);
71+
long cn1DecodeMs = measureCn1Decode(cn1EncodedBytes, cn1DecodedBuffer);
72+
73+
double encodeRatio = cn1EncodeMs / Math.max(1.0, (double) nativeEncodeMs);
74+
double decodeRatio = cn1DecodeMs / Math.max(1.0, (double) nativeDecodeMs);
75+
emitStat("Base64 payload size", payloadBytes.length + " bytes");
76+
emitStat("Base64 benchmark iterations", String.valueOf(ITERATIONS));
77+
emitStat("Base64 native encode", formatMs(nativeEncodeMs));
78+
emitStat("Base64 CN1 encode", formatMs(cn1EncodeMs));
79+
emitStat("Base64 encode ratio (CN1/native)", formatRatio(encodeRatio));
80+
emitStat("Base64 native decode", formatMs(nativeDecodeMs));
81+
emitStat("Base64 CN1 decode", formatMs(cn1DecodeMs));
82+
emitStat("Base64 decode ratio (CN1/native)", formatRatio(decodeRatio));
83+
84+
done();
85+
return true;
86+
}
87+
88+
private static void warmup(Base64Native nativeBase64, String payload, byte[] payloadBytes, String nativeEncoded, byte[] cn1EncodedBytes, byte[] cn1DecodedBuffer) {
89+
for (int i = 0; i < 40; i++) {
90+
nativeBase64.encodeUtf8(payload);
91+
Base64.encodeNoNewline(payloadBytes, cn1EncodedBytes);
92+
nativeBase64.decodeToUtf8(nativeEncoded);
93+
Base64.decode(cn1EncodedBytes, cn1DecodedBuffer);
94+
}
95+
}
96+
97+
private static long measureNativeEncode(Base64Native nativeBase64, String payload) {
98+
long start = System.currentTimeMillis();
99+
for (int i = 0; i < ITERATIONS; i++) {
100+
nativeBase64.encodeUtf8(payload);
101+
}
102+
return System.currentTimeMillis() - start;
103+
}
104+
105+
private static long measureCn1Encode(byte[] payloadBytes, byte[] outputBuffer) {
106+
long start = System.currentTimeMillis();
107+
for (int i = 0; i < ITERATIONS; i++) {
108+
Base64.encodeNoNewline(payloadBytes, outputBuffer);
109+
}
110+
return System.currentTimeMillis() - start;
111+
}
112+
113+
private static long measureNativeDecode(Base64Native nativeBase64, String encoded) {
114+
long start = System.currentTimeMillis();
115+
for (int i = 0; i < ITERATIONS; i++) {
116+
nativeBase64.decodeToUtf8(encoded);
117+
}
118+
return System.currentTimeMillis() - start;
119+
}
120+
121+
private static long measureCn1Decode(byte[] encoded, byte[] outputBuffer) {
122+
long start = System.currentTimeMillis();
123+
for (int i = 0; i < ITERATIONS; i++) {
124+
Base64.decode(encoded, outputBuffer);
125+
}
126+
return System.currentTimeMillis() - start;
127+
}
128+
129+
private static String decodeUtf8(String base64) {
130+
try {
131+
return new String(Base64.decode(base64.getBytes()), "UTF-8");
132+
} catch (Exception ex) {
133+
return null;
134+
}
135+
}
136+
137+
private static String buildPayload() {
138+
StringBuilder sb = new StringBuilder(PAYLOAD_BYTES);
139+
for (int i = 0; i < PAYLOAD_BYTES; i++) {
140+
sb.append((char) ('A' + (i % 26)));
141+
}
142+
return sb.toString();
143+
}
144+
145+
private static boolean isIos() {
146+
String platformName = Display.getInstance().getPlatformName();
147+
return platformName != null && platformName.toLowerCase().contains("ios");
148+
}
149+
150+
private static String formatMs(double millis) {
151+
return formatDecimal(millis, 3) + " ms";
152+
}
153+
154+
private static String formatRatio(double ratio) {
155+
double slowerPct = (ratio - 1.0) * 100.0;
156+
return formatDecimal(ratio, 3) + "x (" + formatDecimal(Math.abs(slowerPct), 1) + "% " + (slowerPct >= 0 ? "slower" : "faster") + ")";
157+
}
158+
159+
private static String formatDecimal(double value, int decimals) {
160+
boolean negative = value < 0;
161+
double abs = Math.abs(value);
162+
long scale = 1;
163+
for (int i = 0; i < decimals; i++) {
164+
scale *= 10;
165+
}
166+
long scaled = Math.round(abs * scale);
167+
long whole = scaled / scale;
168+
long fraction = scaled % scale;
169+
String fractionStr = String.valueOf(fraction);
170+
while (fractionStr.length() < decimals) {
171+
fractionStr = "0" + fractionStr;
172+
}
173+
String formatted = whole + (decimals > 0 ? "." + fractionStr : "");
174+
return negative ? "-" + formatted : formatted;
175+
}
176+
177+
private static void emitStat(String metric, String value) {
178+
System.out.println("CN1SS:STAT:" + metric + ": " + value);
179+
}
180+
}

scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ public final class Cn1ssDeviceRunner extends DeviceRunner {
8484
new VPNDetectionAPITest(),
8585
new CallDetectionAPITest(),
8686
new LocalNotificationOverrideTest(),
87+
new Base64NativePerformanceTest(),
8788
new AccessibilityTest()));
8889

8990
public static void addTest(BaseTest test) {
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#import <Foundation/Foundation.h>
2+
3+
@interface com_codenameone_examples_hellocodenameone_Base64NativeImpl : NSObject {
4+
}
5+
6+
-(NSString*)encodeUtf8:(NSString*)plainText;
7+
-(NSString*)decodeToUtf8:(NSString*)base64Text;
8+
-(BOOL)isSupported;
9+
10+
@end
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
#import "com_codenameone_examples_hellocodenameone_Base64NativeImpl.h"
2+
3+
@implementation com_codenameone_examples_hellocodenameone_Base64NativeImpl
4+
5+
-(NSString*)encodeUtf8:(NSString*)plainText {
6+
if (plainText == nil) {
7+
return nil;
8+
}
9+
NSData *data = [plainText dataUsingEncoding:NSUTF8StringEncoding];
10+
return [data base64EncodedStringWithOptions:0];
11+
}
12+
13+
-(NSString*)decodeToUtf8:(NSString*)base64Text {
14+
if (base64Text == nil) {
15+
return nil;
16+
}
17+
NSData *data = [[NSData alloc] initWithBase64EncodedString:base64Text options:0];
18+
if (data == nil) {
19+
return nil;
20+
}
21+
return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
22+
}
23+
24+
-(BOOL)isSupported {
25+
return YES;
26+
}
27+
28+
@end

scripts/lib/cn1ss.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,7 @@ cn1ss_process_and_report() {
366366

367367
# Pass any stats files found in artifacts
368368
if [ -n "$artifacts_dir" ] && [ -d "$artifacts_dir" ]; then
369-
for stats_file in "$artifacts_dir"/iphone-builder-stats.txt "$artifacts_dir"/ios-test-stats.txt; do
369+
for stats_file in "$artifacts_dir"/iphone-builder-stats.txt "$artifacts_dir"/ios-test-stats.txt "$artifacts_dir"/android-test-stats.txt "$artifacts_dir"/base64-performance-stats.txt; do
370370
if [ -f "$stats_file" ]; then
371371
render_args+=(--extra-stats "$stats_file")
372372
fi

scripts/run-android-instrumentation-tests.sh

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,24 @@ ra_log() { echo "[run-android-instrumentation-tests] $1"; }
88

99
ensure_dir() { mkdir -p "$1" 2>/dev/null || true; }
1010

11+
extract_base64_stats() {
12+
local log_file="$1"
13+
local out_file="$2"
14+
[ -f "$log_file" ] || return 0
15+
16+
local lines
17+
lines="$(grep 'CN1SS:STAT:' "$log_file" 2>/dev/null | sed -E 's/^.*CN1SS:STAT://')" || true
18+
if [ -z "${lines:-}" ]; then
19+
return 0
20+
fi
21+
22+
: > "$out_file"
23+
while IFS= read -r line; do
24+
[ -n "$line" ] || continue
25+
echo "$line" >> "$out_file"
26+
done <<< "$lines"
27+
}
28+
1129
# CN1SS helpers are implemented in Java for easier maintenance
1230
# (Defaults for class names are provided by cn1ss.sh)
1331

@@ -275,6 +293,12 @@ COMPARE_JSON="$SCREENSHOT_TMP_DIR/screenshot-compare.json"
275293
SUMMARY_FILE="$SCREENSHOT_TMP_DIR/screenshot-summary.txt"
276294
COMMENT_FILE="$SCREENSHOT_TMP_DIR/screenshot-comment.md"
277295

296+
BASE64_STATS_FILE="$ARTIFACTS_DIR/base64-performance-stats.txt"
297+
extract_base64_stats "$TEST_LOG" "$BASE64_STATS_FILE"
298+
if [ -s "$BASE64_STATS_FILE" ]; then
299+
ra_log "Base64 benchmark stats captured at $BASE64_STATS_FILE"
300+
fi
301+
278302
export CN1SS_PREVIEW_DIR="$SCREENSHOT_PREVIEW_DIR"
279303
export CN1SS_COMMENT_MARKER="<!-- CN1SS_ANDROID_COMMENT -->"
280304
export CN1SS_COMMENT_LOG_PREFIX="[run-android-device-tests]"

scripts/run-ios-ui-tests.sh

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,24 @@ ri_log() { echo "[run-ios-ui-tests] $1"; }
66

77
ensure_dir() { mkdir -p "$1" 2>/dev/null || true; }
88

9+
extract_base64_stats() {
10+
local log_file="$1"
11+
local out_file="$2"
12+
[ -f "$log_file" ] || return 0
13+
14+
local lines
15+
lines="$(grep 'CN1SS:STAT:' "$log_file" 2>/dev/null | sed -E 's/^.*CN1SS:STAT://')" || true
16+
if [ -z "${lines:-}" ]; then
17+
return 0
18+
fi
19+
20+
: > "$out_file"
21+
while IFS= read -r line; do
22+
[ -n "$line" ] || continue
23+
echo "$line" >> "$out_file"
24+
done <<< "$lines"
25+
}
26+
927
if [ $# -lt 1 ]; then
1028
ri_log "Usage: $0 <workspace_path> [app_bundle] [scheme]" >&2
1129
exit 2
@@ -654,6 +672,11 @@ while true; do
654672
done
655673
END_TIME=$(date +%s)
656674
echo "Test Execution : $(( (END_TIME - START_TIME) * 1000 )) ms" >> "$ARTIFACTS_DIR/ios-test-stats.txt"
675+
BASE64_STATS_FILE="$ARTIFACTS_DIR/base64-performance-stats.txt"
676+
extract_base64_stats "$TEST_LOG" "$BASE64_STATS_FILE"
677+
if [ -s "$BASE64_STATS_FILE" ]; then
678+
ri_log "Base64 benchmark stats captured at $BASE64_STATS_FILE"
679+
fi
657680

658681
sleep 3
659682

0 commit comments

Comments
 (0)