Вступ до Project Panama. Частина 2. Реалізація варіативних функцій
TL; DR. У цій статті досліджується реалізація нативних (Clang) варіативних функції в Java за допомогою Foreign Function & Memory API (Project Panama).
Метою першої частини була спроба познайомити вас із OpenJDK Foreign Function & Memory API, та як реалізувати виклики нативних функцій на прикладі C printf за допомогою відповідних класів API. Як і у минулій статті, тут будуть використовуватись наступні API-класи:
- SymbolLookup — для пошуку адреси символу нативної функції за ії назвою;
- FunctionDescriptor — для створення дескриптору нативної функції у Java, який складається з типу значення, що повертається (return value type) і макетів аргументів (argument layouts) що відповідають сигнатурі нативної C функції згідно якої цей дескриптор й реалізовується;
- Linker — для створення downcall-методу, який базується на дескрипторі нативної функції (FunctionDescriptor) та адреси символу нативної функції.
- MemorySession — для виділення (allocation) та вивільнення (deallocation) сегментів нативної пам’яті поза heap-ом.
Додаток «Hello World» розроблений у минулій статті містив спрощену реалізацію виклику (C ABI downcall) нативної функції C stdio printf у Java. Задля простоти розуміння та входження у технологію, імплементація Java-версії C printf не містила ключового аспекту — варіативних аргументів (variadic arguments).
З точки зору функціональності, відсутність варіативних аргументів зробила реалізацію схожою на метод PrintStream::println, а не на метод PrintStream::printf. Ключова відмінність між якими полягає в підтримці varargs у PrintStream::printf, тому імплементація C printf була фактично реалізацією С puts.
Базуючись на вже отриманному досвіді Першої частини, ця стаття спробує дати відповідь на питання, як реалізувати виклики варіативних функцій Clang за допомогою Foreign Function & Memory API, представленого в Java 19 як preview feature. Тому вкрай важливо прочитати попередню статтю для повноцінності розуміння тематики!
Варіативні функції, варіативні аргументи
У C/C++ варіативна функція (variadic function), наприклад, C printf — це такий тип функції, яка немає чіткого визначення кількості аргументів (variadic argument) у сигнатурі, але перелік аргументів такої функції завжди повинен починатися принаймні з одного іменованого аргументу і завжди повинен закінчуватися «невизначеністю» — ...:
int printf(const char * __restrict, ...);
У C/C++ при реалізації варіативної функції, варіативні аргументи не мають типу даних, пов’язаних із ними, порядку слідування та кількості, але слід зазначити, що «невизначеність» все ж має певні обмеження. Варіативна функція може приймати не більше ніж 127 різноманітних параметрів у межаї одного виклику. Проте, варіативні функції існують лише у тексті коду, бо у процессі компіляції коду (що робить виклики до варіативних функцій) Сlang-компілятор знайде усі виклики до варіативних функцій та створить cкомпільовані версії вже-не-варіативних функцій, сигнатура яких відповідає тим комбінаціям порядку, кількості та типів параметрів, які були використані для виклику варіативних функцій. Тобто для наступних викликів:
printf("hello world");
printf("My name is %s. I'm %d years old", a, b);компілятор створить дві версії C printf із відповідними сигнатурами:
int printf(const char * _restricted); int printf(const char * _restricted, const char * a, const int b);
У Java ситуація дещо інша, varargs, які ми з вами використовуємо, це рантайм-функціонал за який відповідає JIT-компілятор підчас інтерпретації байт-коду всередині JVM, навідміну від мови С/С++ (компіляції С-коду у машинні коди). Така ж ситуація стосується і типізації vararg-ів у Java, вони завжди типізовані:
Object someMethod(Object... varargs);
У розробці додатків на Java часто використовується досить широке визначення типу vararg-ів — за допомогою java.lang.Object, оскільки всі типи Java успадковуються від нього. Враховуючи наведені факти, описані вище, тепер потрібно розібратися як реалізувати варіативні функції мови С за допомогою Foreign Function & Memory API на Java.
Представлення іменованих і варіативних аргументів нативної функції у JVM
Згідно визначенню C printf, спрощенний дескриптор для цієї функції має наступну реалізацію:
FunctionDescriptor function = FunctionDescriptor.of( JAVA_INT.withBitAlignment(32), ADDRESS.withBitAlignment(64) );
Враховуючи те, що C printf є варіативною функцією з іменованими та варіативними аргументами, то цей дескриптор не у повному обсязі відповідає визначенню цієї функції у стандартній бібліотеці С. У дескрипторі немає ніяких ознак варіативних аргументів, а представлені лише іменовані аргументи (ADDRESS.withBitAlignment(64), що відповідає const char * __restricted) та тип значення, що повертається (JAVA_INT.withBitAlignment(32), що відповідає int/int32).
Згідно з визначенням C printf, ця функція має ще варіативні аргументи (...), проте точка входу до виклику застосунку методу (MethodHandle) функції C printf (MethodHandle::invoke) приймає лише аргументи varargs. Виходить так, що з точки зору Java API немає різниці між іменованими та варіативними аргументами, важлива лише послідовність:
public final native @PolymorphicSignature Object invoke(Object... args) throws Throwable;
Таким чином, незалежно від того, яка комбінація іменованих і варіативних аргументів надається для виклику нативної функції у Java, їх потрібно передати або як масив типу java.lang.Object:
var allArgs = new Object[] {namedArg1, ..., NamedArgN, varArg1, ..., varArgN};
methodHandle.asSpreader(Object[].class, allArgs.length).invoke(allArgs);або як vararg-и з варіативними аргументами слідуючими за іменованими, щоб зберегти порядок та типізацію параметрів у відповідності до визначення FunctionDescriptor що був використаний для створення застосунку методу:
methodHandle.invoke(namedArg1, ..., NamedArgN, varArg1, ..., varArgN);
На додаток до сигнатури методу C (значення, що повертається, порядок іменованих аргументів та варіативні аргументи), дескриптор функції також є визначенням очікуваного типу методу (MethodType) у Java для застосунку методу тієї самої нативної функції. Тому, щоб успішно викликати нативну функцію, JVM має знати все про дескриптор функції, застосунок методу (MethodHandle) та спосіб виклику нативного коду через застосунок методу (для створення фактичного типу методу).
Примітка: cам по собі MethodType, як і MethodHandle, є частиною JDK Reflections API, і кожен Java-метод насправді має свій тип методу, як і свій застосунок методу. Тут наведений простий, але наглядний приклад того, як отримати доступ до інформації, яка відображає стан довільного Java-методу та відповідного функціоналу типу MethodHandle::invoke.
Дескриптор нативної функції та тип методу
Поміж усіх об’єктів, пов’язаних із викликами нативних функцій, дескриптор функції є ключовим об’єктом-носієм типу методу, який використовується для валідації на відповідність типу методу, створеного з параметрів, з якими був зроблений виклик нативної функції. Це означає, що під час виклику нативної функції через ії застосунок (MethodHandle), JVM зробить безпечне порівняння типу методу, отриманого безпосередньо із дескриптора функції
// named arg ret-value // <ADDRESS> <JAVA_INT> MethodHandle ( Addressable ) int
і типом методу, створеним з параметрів виклику нативної функції, переданих до MethodHandle::invoke (як іменованих, так і варіативних) під час виклику нативної функціі через ії застосунок. Якщо типи методів не співпадають, то JVM розцінить невідповідність як виняткову (exceptional) ситуацію.
У випадку C printf, JVM перевірить, чи зможе вона безпечно перетворити комбінацію іменованих і варіативних аргументів, переданих до MethodHandle::invoke, на тип методу, отриманий від реалізації Java-дескриптора функції C printf. Наприклад, у минулій статті застосунок методу для C printf викликався з об’єктом типу MemorySegment у якості іменованого параметру, що містить в собі об’єкт типу java.lang.String:
MemorySegment cString = memorySession.allocateUtf8String(str + "\n");
Під час виконання нативної функції
int res = (int) printfMethodHandle.invoke(cString);
JVM створить тип методу за допомогою переданих параметрів
(MemorySegment)int
i cпробує порівняти його до типу методу отриманого із дескриптора функції:
(Addressable)int
Така процедура безпечного перетворення типів буде успішною, оскільки інтерфейс MemorySegment розширює інтерфейс Addressable.
Ключовий момент полягає в тому, що JVM використовує значення, що повертається і макети аргументів визначені у дескрипторі функції для створення типу методу (MethodType). Це означає, що типи аргументів, порядок і кількість, а також тип значення, що повертається, будуть використовуватися JVM підчас виклику нативної функції задля встановлення відповідності типу методу, що базується на параметрах виклику до типу методу, що базується на дескрипторі функції, який був використаний для створення застосунку методу нативної функції. Відповідність цих двох типів є суворою, невідповідность призведе до виключної ситуації.
Отже, робота яку повинен виконати розробник при створенні інфраструктурного коду (дескриптор та застосунок методу) навколо нативного виклику, співвідноситься із тим, що робить CLang-компілятор: є необхідність чітко та однозначно визначити, що й як планується викликати у Java-рантаймі, бо це єдиний спосіб який дозволить JIT-компілятору оптимізувати інфраструктурний код, але про це згодом.
Реалізація варіативних функцій C за допомогою Foreign Function & Memory API
Нове API пропонує метод для явного визначення варіативних аргументів відносно дескриптору функції:
public FunctionDescriptor asVariadic(MemoryLayout... variadicLayouts)
Цікаво те, що макети аргументів, надані через FunctionDescriptor::asVariadic, стануть невід’ємною та обов’язковою частиною типу методу:
var descriptorWithNamedArg = FunctionDescriptor.of(JAVA_INT, ADDRESS);
var descriptorWithNamedAndVariadicArg = descriptorWithNamedArg .asVariadic(ADDRESS, JAVA_INT);
бо з точки зору JVM нема різниці між іменованими чи варіативними аргументами, але як вже зазначено, важливий порядок та типізація:
System.out.println(Linker.downcallType(descriptorWithNamedAndVariadicArg));
(Addressable,Addressable,int)int
Скажімо, ми хочемо реалізувати наступний виклик C printf:
printf("My name is %s, age %d\n", "Denis", 31);Окрім іменованого аргументу const char * __restricted, C printf прийматиме додатково const char * p та int як варіативні аргументи, тому при оголошенні дескриптору функції треба явно зазначити додаткові аргументи у відповідному порядку:
FunctionDescriptor descriptorWithNamedAndVariadicArg = FunctionDescriptor.of( JAVA_INT.withBitAlignment(32), ADDRESS.withBitAlignment(64) ).asVariadic(ADDRESS.withBitAlignment(64), JAVA_INT.withBitAlignment(32));
Порівняно з минулим визначенням C printf
FunctionDescriptor printfDescriptor = FunctionDescriptor.of(JAVA_INT, ADDRESS);
дескриптор descriptorWithNamedAndVariadicArg містить додаткові відомості про варіативні аргументи (типи, порядок і кількість). Виклик функції, визначеної таким описом, буде мати наступний вигляд:
var namedArg = memorySession.allocateUtf8String("My name is %s, age %d\n");
var nameVararg = memorySession.allocateUtf8String("Denis");
var ageVararg = 31;
var ret = (int) printfHandle.invoke(namedArg, nameVararg, ageVararg);JVM перевірить, чи відповідає тип методу визначеного фактичними параметрами виклику до типу методу, визначеного дескриптором функції:
System.out.println(Linker.downcallType(descriptorWithNamedAndVariadicArg));
(Addressable,Addressable,int)int
на тип методу визначеного параметрами:
(MemorySegment,MemorySegment,int)int
І як вже зазначено раніше, порівняння буде вдалим, бо параметри чітко відповідають визначенню дескриптора. Із цього можна зробити наступний висновок, що для будь-яких комбінацій параметрів доведеться створювати відповідну кількість як дескрипторів, так і застосунків методів, що взагалі не є гнучким рішенням. І тут настає саме той момент, коли треба задатися питанням а чи взагалі таке рішення, відповідає «духу» варіативних функцій та аргументів з точки зору Clang у Java?
Виходить так, що у мовах програмування типу С/С++, привабливість варіативних аргументів якраз ґрунтується на їхній варіативній природі, тобто визначення хоч якихось аспектів до самого моменту виклику варіативної функції не є суворо необхідним. Однак у Java ситуація із використанням нативних варіативних функцій інша, є необхідність створення інфраструктурного коду під кожну нативну функцію та під кожну комбінацію варіативних аргументів певних варіативних функцій).
Отже, підхід «як у С/С++» не працює у Java, це значить що наступний код просто не буде працювати:
(int) printfHandle.invoke(namedArg, nameVararg); // method type: (MemorySegment,MemorySegment,Void)int (int) printfHandle.invoke(namedArg); // method type: (MemorySegment,Void,Void)int
бо кожен з цих виклик до C printf буде невдалим і закінчиться із помилкою:
Exception in thread "main" java.lang.RuntimeException: java.lang.invoke.WrongMethodTypeException: cannot convert MethodHandle(Addressable,Addressable,int)int to (MemorySegment,MemorySegment,Void)int
та
Exception in thread "main" java.lang.RuntimeException: java.lang.invoke.WrongMethodTypeException: cannot convert MethodHandle(Addressable,Addressable,int)int to (MemorySegment,Void,Void)int
Щоб мати змогу викликати варіативні функції із різними комбінаціями параметрів, обсяг інфраструктурного коду буде зростати пропорційно до того, як і в якій конфігурації використовуються нативні функції, бо, як зазначено раніше, існує жорстка залежність між параметрами виклику, дескрипторами функцій і застосунками методів, адже виклик застосунку методу із відповідними параметрами завжди повинен відповідати дескриптору функції.
Гнучкість і продуктивність
Слід зазначити, що рішення, засноване на попередньому (in advance) декларуванні дескрипторів із варіативними аргументами та застосунків методів, працюватиме в багатьох випадках, оскільки не всі нативні варіативні функції схожі на C printf. Але у той же час нативні функції, такі як C printf, мають змогу обробляти велику кількість різноманітних аргументів та їх майже нескінченну кількість комбінацій. З точки зору розробки на Java, мета полягає в тому, щоб зберегти той самий рівень гнучкості застосування нативних варіативних функцій у Java без втрати тієї функціональності, яка зазначена у стандарті CLang, але описаному в цій статті рішенню бракує гнучкості, яку пропонують варіативні аргументи.
Рішення, що засноване на попередньому декларуванні варіативних аргументів перетворює їх на невід’ємну частину типу методу, а це означає, що типи та порядок параметрів стають обов’язковими у момент виклику. Тобто, це буде все ще виклик варіативної функції, але JVM повинна знати наперед як саме функція буде викликатися для оптимізації її інфраструктурного коду.
Іншим занепокоєнням є вплив такого підходу на продуктивність Java-додатків, оскільки усі компоненти інфраструктури нативних функцій стають динамічними, а саме: дескриптор функції та застосунок методу. Наприклад, JVM повинна буде створити новий екземпляр дескриптору функції для кожної комбінації варіативних аргументів:
class printf {
private static MemorySegment printfAddr = Linker.defaultLookup().lookup("printf").get();
private static FunctionDesriptor printfSimple = FunctionDesriptor.of(JAVA_INT, ADDRESS);
public static int printfComplex(String fmt, String...parts) {
var newDescriptor = printfSimple.asVariadic(Collections.nCopies(ADDRESS, parts.length));
var newHandle = Linker.downcallHandle(printfAddr, printfSimple);
int re = 0;
try(var sesssion = MemorySession.openConfined()) {
MemorySegment[] arr = new MemorySegment[parts.length + 1];
arr[0] = scope.allocateUtf8String(fmt);
for(int i = 0; i < part.length; i++) {
arr[i+1] = scope.allocateUtf8String(s);
}
res = (int) newHandle.asSpreader(MemorySegment[].class, parts.length + 1).invoke(arr);
}
return res;
}
}Як бачите, на кожен виклик буде створений новий дескриптор та застосунок методу які будуть відповідати вмісту та розмірності vararg-ів, що є повністю неефективним рішенням, бо JIT-компілятор не буде мати змоги оптимізувати таке (динамічне) створення інфраструктурних компонентів, а отже продуктивність додатку просяде.
Проте, можна перейняти практику яку впроваджує Clang-компілятор щодо варіативних функцій. Уявіть що Linker є ще одним компілятором, але вже для нативного коду, який працює у середині рантайму, а застосунки методів (MethodHandle) — як безпосереднє написання С-коду мовою Java. Linker має знати, які застосунки методів потрібно «скомпілювати» для найбільш оптимальної роботи додатку перед фактичним викликом нативних функцій. І чим раніше це станеться (в ідеалі, під час розігріву JVM), тим менше процедура створення інфраструктурного коду вплине на час виконання та продуктивність програми загалом. Отже, з міркувань продуктивності, застосунки методів для кожної варіативної комбінації аргументів слід зберігати як константу (static final) для подальшого використання:
class PrintfImpls {
static final FunctionDescriptor PRINTF_BASE_TYPE = FunctionDescriptor.of(JAVA_INT, ADDRESS);
static final Linker LINKER = Linker.nativeLinker();
static final Addressable PRINTF_ADDR = LINKER.defaultLookup().lookup("printf").orElseThrow();
static MethodHandle specializedPrintf(MemoryLayout... varargLayouts) {
FunctionDescriptor specialized = PRINTF_BASE_TYPE.asVariadic(varargLayouts);
return LINKER.downcallHandle(PRINTF_ADDR, specialized);
}
public static final MethodHandle WithInt = specializedPrintf(JAVA_INT);
public static final MethodHandle WithString = specializedPrintf(ADDRESS);
public static final MethodHandle WithIntAndString = specializedPrintf(JAVA_INT, ADDRESS);
}Підчас розігріву JVM, JIT-компілятор (C2) спробує перевірити та розібрати на складові частини застосунок методу, щоб скомпілювати виклик нативного коду так як виклик будь-якого звичайного методу Java. Але він може зробити це, лише за умови якщо застосунок методу є константою величиною (static final).
Висновки
Існує сильна кореляція між оголошенням нативної функції та способом її виклику, тому нативна функція має бути викликана через застосунок методу рівно так, як вона була оголошена, це ж стосується й нативних варіативних функцій.
У С/С++ компілятор робить справу за вас — створює компільовані типізовані виклики до варіативних функцій, але у Java вам доведеться робити це власноруч. Це означає, що методологія впровадження гнучкості варіативних аргументів полягає у створенні специфічних версій варіативних функцій. Тобто, можливо оголосити варіативну функцію як звичайну, аргументи якої є іменними та складають тип методу нативної функції у Java, що викликається.
Такий підхід вимагає заздалегідь визначити дескриптор функції та застосунок методу у вигляді константних об’єктів, тобто одна варіативна С-функція перетворюється на фактичний набір її специфічних «нащадків», які вже мають явні аргументи, їх кількість, порядок та типізацію. Але такий підхід дещо суперечить природі варіативних функцій, оскільки частіше за все кількість варіативних аргументів, їх типи та порядок є непередбачуваними. А це означає, що наведене рішення не відповідає вимогам «непередбачуванності» або «динамічності» варіативних аргументів у повному обсязі.
Таким чином, розробникам доведеться балансувати між нестачею гнучкості та бажаним рівнем продуктивності, зробивши JVM відповідальним як за оптимізацію статичних застосунків методів, так і за самі виклики нативного коду.
Незважаючи на дещо песимістичний тон, існує альтернативний спосіб вирішити проблему, делегувати створення інфраструктурного коду викликів нативних функцій до JDK code tool jextract — інструменту, розробленого як частину Project Panama, мета якого полягає у генеруванні Java класів на основі C header-ів відповідних бібліотек. Саме цей інструмент і буде темою наступної частини циклу статей про Project Panama.
Код
Код для цієї статті доступний тут.
P.S.
Пам’ятайте про те, що Foreign Function & Memory API доступно у режимі Preview. Для того, щоб працювати з цим API, треба:
- Скомпілюйте програму за допомогою
javac --release 19 --enable-preview Main.javaта запустіть її за допомогоюjava --enable-preview Main. - Використовуючи програму запуску вихідного коду, запустіть програму з
java --source 19 --enable-preview Main.java. - Використовуючи
jshell, почніть його зjshell --enable-preview.
Оригінал мого посту англійською доступний за цим посиланням.
dev.java | inside.java | Java on YouTube | Java on Twitter
Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

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