Векторні обчислення у Java 19
Функціонал, який поставляється у рамках OpenJDK Project Panama, не обмежений лише набором класів для експлуатації C ABI у рамках JVM та новим інструментом кодогенерації — jextract (цикл статтей на цю тему: Частина 1 | Частина 2 | Частина 3 | Частина 4). Дуже важливими нововведеннями для розробників є так звані Vectors API — набір класів, суть котрих полягає в наданні певних можливостей для реалізації векторної алгебри (криптографія, мультимедіа, AI/ML). Отже, ця стаття спрямована на те, щоб на певних прикладах показати, що таке Vectors API, звідки походить та як їх використовувати у Java-додатках.
Single instruction single data (SISD)
Частіше за все двома великими проблемами у реалізації алгебраїчних алгоритмів відносно наборів типових даних (масивів, векторів) ми зтикиємося з двома проблемами:
- Складність алгоритму, що залежить від вкладеності циклів.
- Швидість/час опрацювання усього набору даних.
Ця проблема не є спеціфічною лише для Java, це стосується усіх мов програмування та усіх існучих компіляторів. Це означає, що складнітсь алгоритму ми ще якость можемо контролювати, аналізуючи складність виконання покроково, а от проблему з швидкістю вирішити стає дещо проблематично. Адже навіть параллельне розгалудження обробки масиву даних має свої додаткові витрати у вигляді надлишкового використання пам’яті, ресурсів та часу. Слід також мати на увазі, що найефективніша модель паралелізму при математичних обчисленнях передбачає використання одного потоку на CPU:
int N = 100_000_000;
var one = IntStream.range(0, N).parallel().map(i -> i + 1).toArray();
var two = IntStream.range(0, N).parallel().map(i -> i + 99).toArray();
var result = IntStream.range(0, N).parallel().map(index -> one[index] + two[index]).toArray();
Операції такого типу, як наведені вище, характеризуються як single instruction single data. Проблема таких обчислень полягає у тому, що опрацьовається лише один елемент з набору даних в один проміжок часу. Такий підхід є прийнятним до тих пір, поки накладні витрати на інфраструктуру обчислень не перевищують витрати на самі обчислення (не враховуючи роботу з пам’яттю). Тому, незалежно від галузі виконання обчислень, проблема повинна бути вирішена таким чином, щоб була змога одночасно опрацьовувати декілька елементів набору одночасно.
Single instruction multiple data (SIMD)
Теоретично проблему можна вирішити сегментуванням вхідних данних:
int N = 100_000_000;
var one = IntStream.range(0, N).parallel()
.mapToObj(i -> (int) (i + 1)).toList();
var two = IntStream.range(0, N).parallel()
.mapToObj(i -> (int) (i + 1)).toList();
int step = 2;
var result = IntStream.iterate(0, i -> i < 10, i -> i + step)
.parallel().mapToObj(
index -> Stream.concat(
one.subList(index > step ? index - step : 0, index).stream(),
two.subList(index > step ? index - step : 0, index).stream()
)
.mapToInt(Integer::valueOf).sum())
.toList();
var result = IntStream.iterate(0, i -> i < N, i -> i + 10).parallel()
.map(index -> one[index] + two[index])
.toArray();
Але ми знову бачимо, що проблема нікуди не зникає. У спробі створити ефективне сегментування ми приходимо до того, що більше часу витрачається на сегментування, ніж на обчислення, та й самі конструкції стають складнішими. Тому будь які спроби вирішити питання таким чином не приведуть до якісних результатів, але є вихід — використати процедури типу single instruction multiple data.
З технічної точки зору, SIMD — це апаратні (реалізовані розробниками процесорів) інструкції, які виконують одну і ту ж операцію над декількома операндами даних одночасно (не паралельно, а у межах одного регістру, тобто, інструкція є атомарною). Оскільки процесори випускаються на різних архітектурах (x86, ARM) та належать до різних поколінь (9, 10, 11), їх реалізації SIMD суттєво різняться, але у межах одного бренду лише наслідують та розширяють обчислувальні можливості SIMD. Наприклад, Intel та AMD спільно використовують MMX, SSE2, AVX, в той час як інтерпретація SIMD для ARM (наприклад Apple M1) називається NEON (альтернатива SSE). SIMD архітектури NEON мають ширину 128 біт і включає 16 c = c + a*b). Ці об’єднані операції допомагають виробникам заявляти про маркетингове
Векторні інструкції
Як правило, SIMD включають базові арифметичні операції, такі як додавання, віднімання, множення, заперечення (negate), абсолютний корінь (abs) та квадратний корінь (sqrt) і вже зазначена вище FMA та багато інших. З точки зору розробки, векторні операції виглядають так само, як звичайні бібліотечні функції С/C++:
extern __m128 _mm_add_ps( __m128 _A, __m128 _B );
На відміну від бібліотечних функцій, векторні інсерції реалізуються безпосередньо в компіляторах. Наведена вище _mm_add_ps SSE інсерції зазвичай компілюються в одну інструкцію як, наприклад, addps. Вбудований тип даних __m128 — це структура даних, яка не може вмістити вектор або з чотирьох елементів по 32 біт (int, float), або 2 по 64 (long), але не більше 128 біт. Загалом, імʼя фунції векторних обчислень прямо говорить про те, який тип даних і яку процедуру буде виконувати:
extern __m<bit-length> _mm_<operation>_<vector element type> ( __m<bit-length> _A, __m<bit-length> B)
Слід звернути увагу на відповідний тип елементу вектора. Для Intel AVX інструкцій характерні наступні типи:
ps— packed single-precision (float, 32 bit), наприклад: _mm256,pd— packed double-precision (float, 64 bit).
Для Intel AVX2 наступні:
epi— packed signed integer (short, int, long),epu— packed unsigned integer (byte, short).
Для простоти, сфокусуємося на Intel AVX, увесь перелік векторних інструкцій можна знайти за цим посиланням.
Отже, будь-який код, який містить достатню кількість векторних процедур або інтегрує їх асемблерні еквіваленти (якщо процесор не підтримує певні SIMD нативно, але можна реалізувати їх на основі спецификацій, як це робить SIMD-бібліотека під різні архітектури), називається векторизованим кодом.
Сучасні компілятори та бібліотеки вже багато чого реалізують з використанням SIMD, асемблера або їх комбінації. Наприклад, деякі реалізації стандартних процедур бібліотеки C memset, memcpy або memmove використовують інструкції SSE2 задля забеспечення продуктивності. Проте за межами таких нішевих областей як високопродуктивні обчислення, розробка ігор або компіляторів, роботою з мультимедіа, навіть дуже досвідчені програмісти на C і C ++ в основному не знайомі з особливостями SIMD.
Векторні операції в Java
До впровадження OpenJDK Project Panama векторів у Java не було взагалі, майже ні в якому вигляді, мабудь окрім доступу через JNI. Але ситуація дуже якісно змінюється, бо наразі є цілих два способи використовувати векторі обчислення:
- Використовуючи C ABI, за допомогою JDK Foreign Function & Memory API,
- Використовуючи інтегровані у JVM та JDK, — Vectors API.
Тож почнемо зі складнішого, але вкрай показового способу — інтеграцією Intel AVX 256bit SIMD у Java за допомогою C ABI. Задача стоїть дуже проста — реалізувати функцію додавання двох float векторів.
C ABI реалізація
vector<float> sumOf(vector<float>& one, vector<float>& two) {auto one_size = one.size();
vector<float> result(one_size);
constexpr auto floatsInAVXregister = 8u;
const auto samples = (one_size / floatsInAVXregister) * floatsInAVXregister;
for(int i = 0; i < samples; i += floatsInAVXregister) {auto loaded_one = _mm256_loadu_ps(one.data() + i);
auto loaded_two = _mm256_loadu_ps(two.data() + i);
_mm256_storeu_ps(result.data() + i, _mm256_add_ps(loaded_one, loaded_two));
}
for (int j = samples; j < result.size(); j++) {result[j] = one[j] + two[j];
}
return result;
}
Код вище додає два float-вектори довільної довжини за допомогою
- Розбиття вхідного float-вектору на сегменти по
256-біт (по 8 float елементів). - Завантаження двох сегментів векторів у регістри за допомогою AVX інструкції VMOVUPS.
- Операція додавання двох сегментів та вивантаження результату у новий вектор із зсувом покажчика.
- Додавання залишків векторів, які не взлізли у цикл (менше 8 елементів).
Тепер виникає питання, як зробити цей код доступним з середини Java. Проблема полягає у тому, що за допомогою Foreign Function & Memory API можливо отримати доступ лише до С функцій, але ж код написаний на C++. Отже треба створити інтерфейс, який би дозволив перейти від масиву float-тів до векторів й виконати додавання.
Тут ми застосуємо дещо хитрий, але вкрай дієвий підхід C++, який дозволить абстрагуватися від типу змінної та створити де-ассоційоване посилання на сегмент памʼяті, який зберігає сам вектор без привʼязки до типу. Отже, створимо функцію, яка б трансформувала вектор у де-ассоційоване посилання:
extern "C" void* toVectorPointer(float* array, int size) {vector<float>* ptr = new vector<float>();
for(int i = 0; i < size; i++) {ptr->push_back(array[i]);
}
return reinterpret_cast<void *>(ptr);
}
Цей підхід дозволяє абстрагуватися від типу та створити посилання типу void*, використовуючи reinterpret_cast. Цей метод не компілюється в жодні інструкції процесора (за винятком випадків перетворення між цілими числами та посиланнями або на незрозумілих архітектурах, де представлення вказівника залежить від його типу). Це суто compile-time функціональність, яка вказує компілятору обробляти вираз reinterpret_cast<void *>(vector<float>*) так, як би він мав тип void*. Таким чином результат роботи цього перетворення вже не матиме жодних асоціацій з С++ типами, й буде лише посиланням на сегмент памʼяті. Найціннішим є те, що цю функцію можна використати у С додатках, бо зона доступності цієї функції розширена для всіх, хто намагається викликати ії за допомогою C ABI.
Щоб зрозуміти, як саме викликати цю функцію з середини Java-додатку, треба зробити найпростіше — реалізувати виклик з С коду:
int main() {const int size = 10;
float* _one = (float*) calloc(size, sizeof(float));
float* _two = (float*) calloc(size, sizeof(float));
for (int i = 0; i < size; ++i) {one[i] = i;
two[i] = i + 100;
}
auto voidPtrToOne = toVectorPointer(_one, size);
auto voidPtrToTwo = toVectorPointer(_two, size);
auto result = mm256_add_ps(voidPtrToOne, voidPtrToTwo);
printf("array size: %d\n", size); for (int i = 0; i < size; i++) { printf("%f\n", result[i]);}
free(_one);
free(_two);
}
Я навмисно використовую явне виділення памʼяті (С calloc) та подальшу по-елементну ініціалізацію, бо фактично те ж саме ми будемо робити у JVM.
Отже, код який використовує процессорні SIMD повинен бути скомпільований наступним чином:
g++ -std=c++20 -shared -march=native -fPIC -o vector-simd.platform vector_simd.cpp
Тепер задача реалізувати застосування SIMD у Java. Враховуючи досвід, набутий у минулих статтях, ми вже знаємо, що треба реалізувати для доступу до нативних символів з shared-бібліотеки:
- Створити копію Linker’у.
- Створити пошукову процедуру для нативних символів.
private static final Linker linker = Linker.nativeLinker();
private static final SymbolLookup linkerLookup = linker.defaultLookup();
private static final SymbolLookup loaderLookup = SymbolLookup.loaderLookup();
private static final SymbolLookup symbolLookup = name ->
loaderLookup.lookup(name).or(() -> linkerLookup.lookup(name));
- Створити дескриптори до двох необхідних для нас функцій.
// void* toVectorPointer(float* array, int size);
private static final FunctionDescriptor toVoidPointerDescriptor = FunctionDescriptor.of(
ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.JAVA_INT
);
// float* mm256_add_ps(void* vectorPtrOne, void* vectorPtrTwo);
private static final FunctionDescriptor _mm_256_add_ps_Descriptor = FunctionDescriptor.of(
ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.ADDRESS
);
- Створити необіхдні застосунки нативних методів.
private static final MethodHandle toVoidPointer = symbolLookup.lookup("toVectorPointer").map(memSeg -> linker.downcallHandle(memSeg, toVoidPointerDescriptor)
).orElseThrow();
private static final MethodHandle _mm_256_add_ps = symbolLookup.lookup("mm256_add_ps").map(memSeg -> linker.downcallHandle(memSeg, _mm_256_add_ps_Descriptor)
).orElseThrow();
Саме цікаве, на десерт — відображення Java-обʼєктів на нативну (off-heap) памʼять. Отже, створюємо два Java float-масиви:
float[] one = {1F, 2F, 3F, 4F, 5F, 6F, 7F, 8F, 9F, 10F};float[] two = {10F, 9F, 8F, 7F, 6F, 5F, 4F, 3F, 2F, 1F};Тепер створимо два сегменти нативної памʼяті відповідної розмірності (10 елементів по 4 байти кожний):
var nativeOne = memorySession.allocateArray(ValueLayout.JAVA_FLOAT, one.length);
var nativeTwo = memorySession.allocateArray(ValueLayout.JAVA_FLOAT, two.length);
Як бачите, allocateArray працює так само, як і C calloc, альтернативою буде звичайне виділення памʼяті за процедурою, аналогічною до C malloc:
memorySession.allocate(ValueLayout.JAVA_FLOAT.byteSize() * one.length);
Отже, ми маємо сегмент нативної памʼяті, який може вмістити масиви відповідної розмірності, отже, задача стоїть створити сегмент пам’яті масиву, що моделює пам’ять, асоційовану з Java масивом:
var oneSegment = MemorySegment.ofArray(one);
var twoSegment = MemorySegment.ofArray(two);
Маючи сегмент нативної памʼяті під масив та модель відображення Java-масиву на нативну памʼять, залишається вже на так й багато — скопіювати Java-обʼєкт у нативну памʼять:
nativeOne.copyFrom(oneSegment);
nativeTwo.copyFrom(twoSegment);
Ця процедура типова для усіх ситуацій, коли треба відображати Java-обʼєкти на нативну памʼять. Маючи відображення Java-масиву на нативній памʼяті, ми можемо викликати обидва нативних методи для отримання результату:
// float*, MemoryAddress
var floatArrayPtr = (MemoryAddress) _mm_256_add_ps.invoke(voidPtrOne, voidPtrTwo);
var floatArraySegment = MemorySegment.ofAddress(
floatArrayPtr, ValueLayout.JAVA_FLOAT.byteSize() * one.length, memorySession);
var result = floatArraySegment.toArray(ValueLayout.JAVA_FLOAT);
System.out.println(Arrays.toString(result));
Процедура, описана вище, відображає стандартний підхід для отримання фактичних сегментів памʼяті, що зберігають первні С змінні у тих випадках, коли є лише посилання на сегмент, а не сам сегмент.
Таким чином, ми маємо можливість викликати С++ SIMD інструкції з середини Java, використовуючи функціонал Foreign Function & Memory API. Врахуйте те, що такий підхід досить стандартний для роботи з нативним кодом і не специфічний для векторізації. Це означає, що з Java можна отримати доступ до С++ класів через C ABI.
JDK Vectors API
Доступ до процесорної векторизації хоч і можливий напряму через Foreign Function & Memory API, але не є єдиним. Вже досить довгий час у рамках Project Panama йде розробка нативної підтримки векторизації. Сама ж нативна підтримка полягає у тому, що Vectors API вбудований як у JDK, так у і JVM, а не є надбудовою над Foreign Function & Memory API. Для розуміння API векторизації розглянемо кожний клас у порівнянні до C++ SIMD API і почнемо з визначення вектору:
var size = 10;
VectorSpecies<Float> speciesFloat = FloatVector.SPECIES_256;
var loopBoundSize = speciesFloat.loopBound(size);
float[] one = {1F, 2F, 3F, 4F, 5F, 6F, 7F, 8F, 9F, 10F};float[] two = {10F, 9F, 8F, 7F, 6F, 5F, 4F, 3F, 2F, 1F};float[] result = new float[size];
for (int i = 0; i < loopBoundSize; i += speciesFloat.length()) {FloatVector va = FloatVector.fromArray(speciesFloat, a, i);
FloatVector vb = FloatVector.fromArray(speciesFloat, b, i);
va.add(vb).intoArray(result, i);
}
IntStream.range(upperBound, length).forEach(i -> result[i] = a[i] + b[i]);
Таким чином, ми маємо дещо аналогічну до С++ процедуру, в якій частини масиву, що відповідають певній бітовій довжині (256 біт), завантажуються у регістри для обчислень, і таким же чинов вивантажуються назад для подальшого використання.
У порівнянні з попередньою реалізацією можна побачити, що все сильно спрощується, нема необхідності писати C++/C код, компілювати його та писати код для виклику цих С функцій. Значно зростає якість та зникає необхідність привʼязки до певної архітектури процесора, бо Vectors API працює на всіх відомих архітектурах включно з ARM (цей код, наприклад, писався на Apple M1 Max, а
VectorSpecies<Float> speciesFloat = FloatVector.SPECIES_PREFERRED;
Фактично, таке застосування робить код ще більш стабільним для кросс-платформенного застосування.
Продуктивність векторних обчислень у Java
Усе питання векторизації та обробки векторів обертається навколо ефективності та продуктивності. Теоретично, швидкість обробки одного й того ж вектора у межах звичайного циклу for та відповідної обробки з SIMD є лінійно-залежною, наприклад, додавання двох величин — це стала величина N, то із SIMD це буде від 4N і вище в залежності від розрядності SIMD. Чи насправді це так?
Зробимо наступний синтетичний тест: візьмемо два масиви типу double розмірністю у 1М елементів та реалізуємо процедуру додавання:
- за допомогою SIMD
static double[] sumOf_vectorized(double[] a, double[] b) {var operandSize = a.length;
var result = new double[operandSize];
var upperBound = SPECIES.loopBound(operandSize);
IntStream.iterate(0, i -> i < upperBound, i -> i + SPECIES.length()).forEachOrdered(i -> {var va = DoubleVector.fromArray(SPECIES, a, i);
var vb = DoubleVector.fromArray(SPECIES, b, i);
var vc = va.add(vb);
vc.intoArray(result, i);
});
for (int i = upperBound; i < a.length; i++) {result[i] = a[i] + b[i];
}
return result;
}
- у циклі for
static double[] sumOf_forLoop(double[] a, double[] b) {
var operandSize = a.length;
var result = new double[operandSize];
for(int i = 0; i < operandSize; i++) {
result[i] = a[i] + b[i];
}
return result;
}- у межах стріму
static double[] sumOf_streamed(double[] a, double[] b) {return IntStream.range(0, a.length).mapToDouble(index -> a[index] + b[index]).toArray();
}
Сам тест буде мати наступний вигляд:
record evolving(long[] vectorized, long[] streamed, long[] forLoop) {}static evolving test(int tries) { System.out.printf("Number of tries: %d\n", tries);var vectorSize = 1_000_000;
var r = new Random();
Supplier<double[]> supplier = () -> IntStream.range(0, vectorSize)
.mapToDouble(i -> i * r.nextDouble()).toArray();
var a = supplier.get();
var b = supplier.get();
var vectorized = IntStream.range(0, tries).mapToLong(index -> {var now = Instant.now();
sumOf_vectorized(a, b);
var later = Instant.now();
return Duration.between(now, later).toMillis();
}).toArray();
var vectorizedAverage = Arrays.stream(vectorized).average().getAsDouble();
System.out.printf("Average vectored sum took %f milliseconds.\n", vectorizedAverage); var streamed = IntStream.range(0, tries).mapToLong(index -> {var now = Instant.now();
sumOf_streamed(a, b);
var later = Instant.now();
return Duration.between(now, later).toMillis();
}).toArray();
var streamedAverage = Arrays.stream(streamed).average().getAsDouble();
System.out.printf("Average stream-bound sum took %f milliseconds.\n", streamedAverage); var forLoop = IntStream.range(0, tries).mapToLong(index -> {var now = Instant.now();
sumOf_streamed(a, b);
var later = Instant.now();
return Duration.between(now, later).toMillis();
}).toArray();
var forLoopAverage = Arrays.stream(forLoop).average().getAsDouble();
System.out.printf("Average for-loop-bound sum took %f milliseconds.\n", forLoopAverage);return new evolving(vectorized, streamed, forLoop);
}
Отже, у межах прогонів (tries) ми спробуємо знайти середній час виконання однієї операції для того, щоб зрозуміти, скільки ж саме часу буде виконуватися кожна з імплементацій. Подивимося на поведінку JVM відносно цих реализацій на 30 000 прогонів, але спочатку подивимося на простий запуск в один цикл:
Average vectorized sum took 355.000000 milliseconds.
Average stream-bound sum took 235.000000 milliseconds.
Average for-loop-bound sum took 7.000000 milliseconds.
Як можна побачити, що лише цикл for вийшов більш-менш оптимізований. Але це й не дивно, С2-компілятор робить усе можливе для оптимізації простих конструкцій, роблячи їх «гарячими» за замовчанням, а от зі стрімами та векторами ситуація зовсім інша, лише за наявності достатньої кількості викликів JVM оптимізує код, роблячи його надпотужно швидким. В цьому зараз і впевнимося:
Average vectored sum took 0.305233 milliseconds.
Average stream-bound sum took 5.872133 milliseconds.
Average for-loop-bound sum took 5.903900 milliseconds.
Після 30 000 операцій додавань мільйонних векторів ми бачимо, що швидкість обробки у векторів більш як в 10 разів вища, що й треба було довести: SIMD є найкращим рішенням для векторної алгебри у Java. Прискіпливі читачі запитають: «1 за 30К прогонів, а можна щось посередині?» Так, авжеж, використовучи данні минулого тесту маємо наступні графіки:
- векторна реалізація;

- цикл
for;

- стрімінг;

Отже, можна впевнитися, що SIMD реалізація у JDK (та JVM) є найкращим варіантом. Але мій найулюбленійший факт при підготовці цієї статті полягає у тому, що впровадження паралельних обчислень на основі платформних потоків лише додає часу, а не ефективності.
«JMH або нічого», робимо правильний бенчмаркінг
Синтетичний тест, наведений вище, загалом є показовим, але в нього закладена похибка — явно векторизована версія та цикл for повинні давати приблизно однакові результати завдяки автовекторизації, яка відбувається неявно завдяки JVM. Це може вказувати на той факт, що автовекторизація іноді може бути вразливою відносно написаного коду, що певним чином пов’язано з тим, як проводиться бенчмаркінг. Тому правильним і єдиним рішенням буде використати Java Microbenchmark Harness. Отже, розглянемо тий самий тест, але який виконується JMH:
@BenchmarkMode({Mode.All})
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
@Warmup(iterations = 100)
@Measurement(iterations = 10_000)
@Fork(value = 1)
public class Benchmark {
double[] a;
double[] b;
@Setup
public void setup() {
a = Task.supplier.get();
b = Task.supplier.get();
}
@org.openjdk.jmh.annotations.Benchmark
@BenchmarkMode(Mode.AverageTime)
public void benchmarkVectorized() {
Task.sumOf_vectorized(a, b);
}
@org.openjdk.jmh.annotations.Benchmark
@BenchmarkMode(Mode.AverageTime)
public void benchmarkForLoop() {
Task.sumOf_forLoop(a, b);
}
@org.openjdk.jmh.annotations.Benchmark
@BenchmarkMode(Mode.AverageTime)
public void benchmarkStreamed() {
Task.sumOf_streamed(a, b);
}
public static void main(String[] args) throws Exception {
org.openjdk.jmh.Main.main(args);
}}
Фактичні показники тесту мають наступний вигляд:
Benchmark Mode Cnt Score Error Units
Benchmark.benchmarkForLoop avgt 10000 0.437 ± 0.002 ms/op
Benchmark.benchmarkStreamed avgt 10000 3.693 ± 0.007 ms/op
Benchmark.benchmarkVectorized avgt 10000 0.470 ± 0.002 ms/op
Ці результати таки показують, що явна та автовекторизація мають майже однаковий час виконання, у той же час, ми бачимо трохи інший час відносного виконання додавання у стрімах, а фактично співвідношення трохи відрізняється від синтетичного тесту, та становить 8.4 рази. З точки зору програмування ми бачимо: такий результат свідчить про факт того, що накладні витрати на інфраструктуру дуже високі, а ще це значить, що є вкрай велика необхідність тестувати час роботи кожного алгоритму/процедури, яка потенційно може бути оптимізована C2-компілятором.
Трохи висновків
Вже деякий час у JDK є можливість працювати з векторною алгеброю, на даний час все ще у форматі preview feature. З точки зору програмування, робота з векторною алгеброю є майже такою, як і в С++ — це дуже велика заслуга розробників JDK. З точки зору простоти застосування — JDK надає безпечніший доступ до SIMD, особливо у теперішній час, коли велика кількість Java розробників вже працює на Apple M1 ноутбуках, та й взагалі на ARM архітектурі, де набір SIMD суттєво відрізняється від набору й реалізацій векторної алгебри на процесорах Intel та AMD.
Щодо швидкодії, то графіки наведені вище це чітко показують, що в JDK нема жодної реалізації, яка б перевершила імплементацію векторної алгебри, що базується на SIMD.
Код статті
... доступний тут. Всі класи викладені тут, включно з бенчмарком.
P.S.
Пам’ятайте про те, що Foreign Function & Memory API доступно у режимі Preview, а Vectors API — все ще в інкубаторі. Щоб працювати з цим API, треба:
- Скомпілюйте програму за допомогою
javac --release 19 --enable-preview Main.javaта запустіть її за допомогоюjava --enable-preview Main. - Використовуючи програму запуску вихідного коду, запустіть програму з
java --source 19 --enable-preview Main.java. - Використовуючи
jshell, почніть його зjshell --enable-preview. - Додайте відповідний модуль
--add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED.
----
dev.java | inside.java | Java on YouTube | Java on Twitter | Me on Twitter
Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

5 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів