В данном посте, я расскажу про интересный случай возникший у меня работе.
Есть следующий код:
public String pack(final byte[] data) {
int bufferIdx = 0;
char[] buffer;
if (data.length % 2 != 0) {
buffer = new char[(data.length >> 1) + 2];
buffer[0] = flag;
bufferIdx = 1;
} else {
buffer = new char[data.length >> 1];
}
int idx = 0;
for (int i = bufferIdx; i < buffer.length; i++) {
int bpos = idx << 1;
char c;
if (bpos + 1 < data.length) {
c = (char) (((data[bpos] & 0x00FF) << 8) + (data[bpos + 1] & 0x00FF));
} else {
c = (char) (((data[bpos] & 0x00FF) << 8));
}
buffer[i] = c;
idx++;
}
return new String(buffer);
}
В методе проиcходит сериализация массива байт. Используется тот факт, что размер char
в java — 16 бит, а размер byte
(Внезапно 😱) — 8 бит. Но суть не в этом. Интересный момент здесь заключается в том, что метод работает гораздо медленее на массивах нечетной длины.
Очевидно, что большую часть времени выполнение происходит в цикле. Различные профилировщики это подтверждали. В данной ситуации, смотреть байткод смысла было нет, т.к. компилятор в момент своей работы понятия не имеет, какой длины будет передан массив.
Значит применялись какая то оптимизации в рантайме. Это мог только сделать jit компилятор. Для проверки гипотизы, я воспользовался иструментом JitWatcher. Эта программа позволяет посмотреть сгенерированный asm для каждой стадии оптимизации.
Небольшой offtop:
JitWatcher использует параметры jvm для отображения сгенерированного кода. Собственно никто не запрещает это сделать самостоятельно, запустив вашу программу с ключиками:
-XX:+UnlockDiagnosticVMOptions -XX:+LogCompilation -XX:+TraceClassLoading -XX:+PrintAssembly
Но поверьте, вы не захотите в этом разбираться. C JitWatcher вам не придется перелопачивать тонны asm кода, хотя вдруг вам это понравится 😏
Чтобы ключик -XX:+PrintAssembly
заработал jvm требует наличие библиотеки hsdis. Она должна находиться в директории $JAVA_HOME/jre/lib/server/
. Ее можно либо собрать самостоятельно, либо скачать уже готовый артифакт, например здесь
Но вернемся к нашим баранам. Выяснилось, что jit делает «странное» предположение. Оно заключается в следующем — для массивов четной длины, использовались векторые инструкции, а для работы с массивами нечетной длины он решил их не использовать.
Ясно, что векторная операции это именно то, что может дать существенный прирост производительности. Потребовалось переписать цикл таким образом, чтобы jit использовал их в обоих случаях. Я применил unrolling цикла.
int countOfIteration = data.length / 2;
for (int index = 0; index < countOfIteration; index++) {
final int position = index * 2;
char firstPart = (char) ((data[position] & 0x00FF) << 8);
char secondPart = (char) (data[position + 1] & 0x00FF);
buffer[index] = (char) (firstPart + secondPart);
}
Тут то jit’у было не отвертеться и он стал стабильно компилировать ассемблеровский код с векторными интрукциями независимо от размера массива:
Результаты измерений показывающие разницу:
Benchmark | Size of array | Mode | Score | Units |
---|---|---|---|---|
with optimization | 100000 | avgt | 46777.0331 | ns/op |
with optimization | 100001 | avgt | 47718.458 | ns/op |
without optimization | 100000 | avgt | 47218.527 | ns/op |
without optimization | 100001 | avgt | 71312.515 | ns/op |