Kotlin 코드가 CPython Stable ABI 함수를 호출하는 방향. 반대 방향은
upcall.md.
현재 상태 (2026-10-03 기준, 코드로 확인). 플랫폼별 다운콜 경로는 각
EmbedAPI.<platform>.kt+bindings.kt가 직접 구현한다: 데스크톱은 PanamaMethodHandle+invokeExact(포인터는JAVA_LONG), Android 는RegisterNatives로 등록한 JNI (함수별·API 레벨별 호출 규약), Native 는 cinterop. 아래 "shape 트램폴린" 14종은 어휘(vocabulary)로 구현되어 있으나(jvmMain/.../ShapeDowncalls.kt의expect+ 데스크톱·Android·Native 구현) 330개actual을jvmMain한 벌로 합치는 통합에는 쓰이지 않았다 — 소스에서 shape 함수를 부르는 곳은 데스크톱ProxyTypeFactory의tp_traverse/tp_free호출 두 곳뿐이다. 이 문서의 shape 설계 절은 그 계획과 근거의 기록이다.
EmbedAPI.kt 의 약 330개 expect 선언을 전수 분석한 결과다 (조사 시점의 수. 현재 expect 선언은 314줄로 센다).
| 항목 | 결과 |
|---|---|
| 전체 함수 | 330 |
| 구분되는 ABI shape | 14 |
| 최대 인자 수 | 6 (PyErr_WarnExplicit) |
| 부동소수점 관련 함수 | 6개뿐 |
| 정수/부동소수 혼합 시그니처 | 0 |
| 가변인자 | 0 |
| 구조체 값 전달 | 0 |
부동소수점을 쓰는 함수는 PyLong_FromDouble, PyLong_AsDouble, PyFloat_FromDouble,
PyFloat_AsDouble, PyFloat_GetMax, PyFloat_GetMin 여섯 개가 전부다.
shape 를 가르는 기준은 arm64(AAPCS64)와 x86-64(SysV) 모두에서 같다 — 정수·포인터가 한 레지스터 뱅크, 부동소수점이 다른 뱅크를 쓰므로, (정수형 인자 수, 부동형 인자 수, 반환 종류) 가 shape 다. 혼합 시그니처가 0개라 트램폴린의 C 프로토타입이 단순해진다.
결론: 14개 트램폴린이면 CPython Stable ABI 전체를 덮는다.
Kotlin/Native 가 헤더를 읽어 직접 호출 코드를 낸다. 오버헤드가 없고 손댈 것도 없다. 이미 최적이다.
desktopMain/.../Panama.kt 가 java.lang.foreign(JDK 22+, 1921 은 preview)과
18)을 둘 다 리플렉션으로만 접근한다. 어느 API 도 직접 import 하지
않으므로 어떤 JDK 에서도 컴파일되고, 런타임에 백엔드를 고른다.jdk.incubator.foreign(JDK 16
리플렉션은 초기화 때 한 번만 돌고 이후는 캐시된 MethodHandle 을 쓴다.
성능 문제는 해결되었다. 초기에는 호출부 305곳이 MethodHandle.invoke(...) as Long 이었고 호출마다
asType 변환과 박싱이 일어나 PyList_Size 한 번이 약 1016 ns 였다. 포인터를 ADDRESS 대신
ValueLayout.JAVA_LONG 으로 기술하고 호출부를 invokeExact 로 바꿔 2.65 ns 가 되었다 (아래
"Fixed: 1015.95 ns -> 2.65 ns"). 현재 desktopMain/.../bindings.kt 에는 invokeExact 315곳이 있고
.invoke( 는 0곳이다 (AGENTS.md §16: 무조건 invokeExact). 리플렉션은 핸들을 만들 때 심볼당 한 번만
돈다. Linker.Option.critical() 은 쓰지 않는다 (downcallHandle(..., emptyOptions)).
ART 는 런타임에 임의 시그니처의 네이티브 호출을 만들 수 없다. 그래서 shape 마다 트램폴린을 미리 컴파일해두고, 대상 함수 포인터를 첫 인자로 받는다:
// Kotlin/Native 로 컴파일해 .so 에 넣는다 (NDK 불필요)
@CName("...downcallJ_J")
fun downcallJ_J(fn: Long, a0: Long): Long =
fn.toCPointer<CFunction<(Long) -> Long>>()!!.invoke(a0)주소는 런타임에 무엇이든 될 수 있고, 고정되는 것은 시그니처 형태뿐이다.
도달 방법은 두 단계로 계획했다:
| 단계 | 경로 | 상태 |
|---|---|---|
| 1 | 일반 JNI → 트램폴린 → 간접 호출 | 구현됨. androidMain/.../bindings.kt 의 downcall* external fun 14개가 jni_onload.def 에서 등록되고, Kotlin/Native 트램폴린(nativeMain/.../EmbedAPI.native.kt)이 함수 포인터를 직접 호출한다. ShapeDowncalls.android.kt 가 감싼다 |
| 2 | ART 엔트리 포인트 패치 + @CriticalNative → 트램폴린 |
하지 않았다. @CriticalNative 는 API 34+ 에서 오히려 비싸다고 실측됐고(아래), 호출 규약은 함수별로 고르는 것으로 정리됐다 |
실제 Android 바인딩은 shape 를 거치지 않고 함수마다 RegisterNatives 로 등록한 JNI 를 쓴다
(androidMain/README.md). shape 트램폴린은 ffiSymbol 로 얻은 임의 주소를 부르는 일반 수단으로
남아 있다.
LLVM JIT 은 필요 없다 — 다만 근거는 아래와 같다.
ART 에는 FFM 을 위한 VM 지원이 없다. HotSpot 은 downcallHandle 호출 시 VM 이 스텁을 기계어로
생성해 주지만, ART 는 해 주지 않는다. 그래서 임의의 FunctionDescriptor 에 대해 인자를 레지스터에
배치하고 대상을 호출하는 기계어를 누군가는 만들어야 한다. PanamaPort 는 그것을 런타임에
libLLVM.so 로 만든다 — _AndroidLinkerImpl.generateNativeDowncallStub(292행)이 다운콜 경로에서
조건 없이 호출되며(938행), 업콜도 generateNativeUpcallStub(954행)으로 같다.
주목할 점은 그 생성 스텁도 대상 함수 포인터를 첫 인자로 받는다는 것이다:
// _AndroidLinkerImpl.java:935
stub_descriptor = stub_descriptor.insertArgumentLayouts(0, WORD); // leading function pointer우리 downcallII_I(fn, a0, a1) 과 같은 구조다. 차이는 생성 시점뿐이다 (PanamaPort 는 의존성으로 쓰지
않는다 — AGENTS.md §12.9. 아래는 설계 근거로 읽은 분석이다; android-ffm-design.md):
| PanamaPort | 이 프로젝트 | |
|---|---|---|
| 스텁 생성 | 런타임 (LLVM JIT) | 빌드 타임 (미리 컴파일) |
| 커버 범위 | 임의 시그니처 | shape 14종 |
libLLVM.so 의존 |
필요 | 불필요 |
즉 LLVM 이 필요 없는 이유는 "참조 구현도 안 쓰기 때문"이 아니라 **"시그니처를 14종으로 열거해 빌드 타임에 만들어 두기 때문"**이다.
(이전 판에서 BulkLinker.requireNativeStub 이 false 를 반환하므로 참조 구현도 스텁 없이 직접
패치한다고 서술했으나 이는 오독이었다. BulkLinker 는 Unsafe 모듈에 있는 PanamaPort 자체
부트스트랩 수단이며 — LLVM API 같은 내부 네이티브 함수를 바인딩한다 — Core 의 Linker 경로에서는
쓰이지 않는다.)
계획은 이랬다. 당시 JVM 쪽은 330개 actual 이 두 벌 있고 플랫폼별 bindings 선언까지 더하면 손으로
유지하는 선언이 1,300개에 가까웠다. shape 를 도입하면 플랫폼별 표면이 14개로 줄고 330개 actual 은
jvmMain 에 한 벌만 남는다:
commonMain expect fun PyList_Size(list: NativePointer): Long
jvmMain actual fun PyList_Size(list) = downcallJ_J(sym.PyList_Size, list.toRawValue())
internal expect fun downcallJ_J(fn: Long, a0: Long): Long ← shape 14개
├── desktopMain internal actual → 캐시된 Panama 핸들
└── androidMain internal actual → shape 트램폴린
인터페이스나 추상 클래스를 쓰지 않는다. expect/actual 이 같은 일을 하는 언어 기능이고,
인터페이스를 두면 호출마다 가상 디스패치가 붙는다.
실험으로 확인한 절차(함수 하나 PyList_Size 로 4개 타깃 컴파일): commonMain expect 에서 inline
제거 → 플랫폼 actual 삭제 → jvmMain 에 actual 하나 → shape 함수를 internal expect.
현재 상태: 이 통합은 진행되지 않았다. jvmMain 에는 shape 어휘(ShapeDowncalls.kt)와 문자열
마샬링 expect(internedUtf8 등)만 있고, 330개 actual 은 여전히 desktopMain 과 androidMain 에 각각
있다. 이유는 아래 실측들이다: 데스크톱은 invokeExact 로 2.65 ns 에 도달해 shape 가 필요 없었고, Android 는
함수별 호출 규약 선택이 shape 단일화보다 비용을 크게 좌우했다. expect inline fun + 중간 소스셋 조합의
컴파일러 크래시 제약(AGENTS.md §16)도 그대로 남아 있다.
Native Image 는 FFM 다운콜을 빌드 타임에 등록해야 한다. reflect-config.json 으로는 안 되고,
RuntimeForeignAccess.registerForDowncall(descriptor, options) 를 쓰는 등록(여기서는 reachability
메타데이터)이 필요하며, -H:+ForeignAPISupport 로 활성화하는 실험적 기능이다. 데스크톱이 연결하는
모든 FunctionDescriptor 가 그 메타데이터에 선언되어 있는지는 ReachabilityMetadataTest.kt 가 지킨다
(SPEC C-2). JAVA_LONG/shape 로 시그니처 종류가 한정되는 것이 등록을 단순하게 한다.
이것은 더 이상 "우선순위 낮음"이 아니다 — 업콜이 네이티브 이미지에서 동작해야 한다는 것은 이 저장소의
결정이다 (AGENTS.md §13). 절차와 마지막 검증 기록은
graal-native-image-verification.md (SPEC U-6: 수동 검증,
이미지 자체를 테스트하는 자동화는 아직 없음).
다운콜은 전부 GIL(또는 free-threaded 빌드의 스레드 상태)을 요구한다 — withGIL { }
(python/multiplatform/ffi/GILScope.kt). threading-and-abi.md 를 본다.
위의 shape 설계는 Desktop 에는 유효하지만 Android 에는 그대로 적용되지 않는다. 설계 논의에서 드러난 사실과, 그 결과 실제로 택한 방식을 기록한다.
초기에는 @CName 으로 만든 C 함수(JNIEnv/jclass 를 받지 않음)를 일반 JNI 메서드처럼 이름으로
연결해서, ART 가 넘기는 JNIEnv*·jclass 때문에 인자가 두 칸 밀렸다. 지금은 JNI_OnLoad
(artMain)가 RegisterNatives 로 모든 external fun 을 등록한다 (367개 중 363개; 예외 4개는 의도적 —
ROADMAP §2). 이름 기반 연결은 API 26 에서 런타임을 abort 시키기도 한다. 초안의 진단·표는
../archive/downcall-design-android-first-diagnosis.md.
호출 규약은 per-API-level 이면서 per-function 이다. 전환 비용은 아래 "Measured" 절의 실측을 따른다
(API 34 를 경계로 @CriticalNative 와 @FastNative 가 뒤집힌다). @CriticalNative 는 RegisterNatives
로만 등록하고, 정적·원시 타입만, 그리고 JVM 으로 되돌아올 수 없는 함수에만 쓴다.
Linker.Option.critical() 과 @CriticalNative 는 제약이 같다. 짧고 블로킹하지 않아야 하며,
콜백으로 JVM 에 되돌아오면 안 된다. 위반은 정의되지 않은 동작이고 런타임이 보호해 주지 않는다.
이 프로젝트는 업콜이 핵심 요구사항이므로 이 제약이 결정적이다:
| 함수 | critical |
|---|---|
PyList_Size, Py_IncRef, PyLong_FromLongLong |
적용 가능 |
PyObject_Call, PyRun_String, PyImport_ImportModule |
불가 — Python 코드를 실행하며 Kotlin 으로 되돌아올 수 있다 |
가장 뜨거운 호출들이 정확히 critical 을 못 쓰는 쪽이다. 그리고 shape 는 이를 구분하지 못한다 —
downcallII_I 하나가 PyList_GetItem(가능)과 PyObject_Call(불가)을 함께 덮는다. 시그니처가
같아도 성격이 반대다.
Desktop 과 Android 를 같은 방식으로 통일하려고 Android 에 FFM 을 얹어도, API 형태만 통일되고 성능 특성은 통일되지 않는다.
| Desktop (HotSpot) | Android (PanamaPort 류) | |
|---|---|---|
| 스텁 생성 | VM 이 기계어로 생성 | ART 메서드 엔트리 포인트를 패치 |
| JIT 가시성 | MethodHandle 을 C2 가 인라인·특수화 |
ART JIT 는 그만큼 못 함 |
| 중간 계층 | 없음 | MethodHandle + 자체 변환 계층 |
| 인자 마샬링 | VM 생성 코드 안 | Java/Kotlin 코드 |
| 전환 생략 | Linker.Option.critical() |
@CriticalNative (대응됨) |
전환 비용 자체는 대응되지만, 그 위 계층에서 갈린다. Desktop 의 Panama 는 런타임의 일부이고, Android 의 Panama 는 런타임인 척하는 라이브러리다. 같은 바닥 위에 계층이 더 있고 JIT 가 약하므로 Android 쪽이 더 빠를 수는 없다. 다만 얼마나 다른지는 측정된 바 없다.
우리 desktop 구현은 현재 Linker.Option.critical() 을 쓰지 않는다 —
Panama.kt 가 downcallHandle(..., emptyOptions) 로 기본 경로를 탄다.
경계 통과가 비싸고 뜨거운 호출에는 critical 도 못 쓰므로 통과 횟수 자체를 줄이는 것이 접근이었다 —
bindings 의 저수준 함수들을 바인더 레벨 연산으로 조립해 artMain 에서 내보내고 androidMain 은 그
굵은 단위를 한 번 부른다 (artMain 의 asmGetAttr 등; 조립 단위는 길고 업콜 가능성이 있으므로 일반
JNI, shape 계열은 짧고 콜백이 없으므로 규약을 함수별로 선택).
초안은 세 후보를 두고 결정을 미뤘다(바인더 API 를 expect/actual 로 올림 / commonMain 에 두고 Android
만 별도 경로 / Python3 수준만 조립). 택한 것은 어느 것도 아니다: 조립은 FFI 층의 expect/actual
(EmbedAPI)에 두고 객체 모델은 공통으로 둔다 — 객체 모델은 bindings 를 참조하지 않는다
(architecture.md "Consequence for composed operations", commonMain/README.md). 그리고 문자열을
나르는 연산에서는 조립보다 interning 이 더 싸고 일반적이라 그쪽으로 갔다
(marshalling-design.md); 조립이 남는 곳은 통과 횟수가 N 에 비례하는 일괄
연산(1000원소 리스트 변환 11배)이다. 데스크톱에서는 조립을 하지 않기로 닫혔다 (ROADMAP §6: 마샬링이
호출의 10.5%).
Every overhead figure in this document before this section was quoted from external benchmarks or inferred from source. This section is the first measurement taken in this project, and it contradicts the assumption the Android design was built on.
echo0, echoFast and echoNormal are the same one-line C body — jlong f(jlong x) { return x; } — registered three times under the three conventions
(artMain/cinterop/jni_onload.def). The callee work is identical and negligible, so the
difference between them is the transition and nothing else. A pure-Kotlin identity call is
measured alongside as the floor and subtracted.
All variants are warmed up before any are timed, and rounds interleave all four, so drift
hits them equally. Best-of-7 rounds, 2,000,000 iterations each. JniOverheadBenchmark.
| convention | API 26 (Android 8.0) | API 34 (Android 14) |
|---|---|---|
@CriticalNative |
1.86 | 24.43 |
@FastNative |
38.58 | 1.99 |
| ordinary JNI | 45.56 | 8.05 |
The two devices are inverted. On API 26 @CriticalNative is ~24x cheaper than ordinary
JNI, which is what the design predicted. On API 34 it is the most expensive of the three
— 3x worse than ordinary JNI and 12x worse than @FastNative.
- Measurement order. An earlier version warmed and timed one variant at a time; whichever native variant went first looked anomalous. Interleaving changed nothing: API 26 stayed at 1.86, API 34 stayed at 24.43.
- GC starvation from the tight loop. A
@CriticalNativecall blocks GC, so millions of them back to back could have been charging collector waits to the loop. Re-measured at 20,000 iterations instead of 2,000,000: API 34 critical stayed at 26.49 net. Batch size is not the explanation. - Registration silently failing.
argumentsArriveUnshiftedpasses on both devices, so the critical convention is in effect on API 34 — arguments arrive unshifted. It is correct there, just slow.
Why API 34 is slow has not been established. The inversion is suspiciously symmetric — each device looks like it honours exactly one of the two annotations on a fast path and routes the other through a generic trampoline that is correct but slow. That is a hypothesis, not a finding.
These are emulators under Apple Silicon virtualisation. Absolute nanoseconds say nothing about real hardware, and the inversion itself may be an emulator artefact. Before acting on this, measure on physical devices at both API levels.
If this holds on hardware, the current design is optimal at minSdk and worst-case on modern
Android, which is backwards from where the users are. The fix is to pick the convention per API
level. The annotation is compile-time, so that means declaring both variants and dispatching on
Build.VERSION.SDK_INT — a branch costing ~1-2ns to avoid ~22ns. (It held on hardware, below, and
was then built: bindings.preferFastNative.)
The section above asked for physical-device confirmation before acting. Done: Samsung SM-X910 (Galaxy Tab S9 Ultra), Android 16 / API 36, arm64-v8a, 8 cores. It is not an emulator artefact, and hardware is worse than the emulator was.
Net of the Kotlin floor, ns/call:
| convention | API 26 (emu) | API 34 (emu) | API 36 (hardware) |
|---|---|---|---|
@CriticalNative via RegisterNatives |
1.83 | 24.35 | 46.68 |
@CriticalNative via name linking |
aborts the runtime | 66.80 | 76.56 |
@FastNative |
37.80 | 1.97 | 2.52 |
| ordinary JNI | 44.79 | 7.81 | 16.29 |
Three things are now settled.
The binding method is not the cause. The hypothesis was that explicitly-registered
critical natives lose the fast path on modern ART while name-linked ones keep it — Google's
advice to use RegisterNatives before Android 12 reads as if the reverse holds after. So a
name-linked @CriticalNative echo was added, identical in every other way. It is slower
still: 66.80ns on API 34 and 76.56ns on API 36 against 24.35 and 46.68 for the registered
one. Hypothesis rejected. @CriticalNative is simply expensive on modern ART.
Name linking is not merely slower below Android 12 — it is fatal. On API 26 that call
aborted the ART runtime outright (zygote64: runtime.cc:492] Runtime aborting...) and took
the instrumentation process with it. Google's guidance turns out to be a hard requirement,
not a preference. The benchmark now guards that call behind SDK_INT >= S.
@FastNative is the fast path on modern Android, and @CriticalNative is on old. They
are cleanly inverted: ~2ns vs ~38ns on API 26, ~2.5ns vs ~47ns on API 36. Whatever the
underlying reason, the practical shape is unambiguous.
The 11 migrated functions use @CriticalNative via RegisterNatives. That is the best
available choice at minSdk 26 and the worst on the hardware people actually carry —
46.68ns where @FastNative costs 2.52ns, an 18x penalty.
The annotation is resolved at compile time, so the convention cannot be switched at runtime
for one declaration. The fix is to declare both variants and dispatch on
Build.VERSION.SDK_INT, paying one predictable branch (~1-2ns) to avoid ~44ns. @FastNative
still receives JNIEnv and jclass, so its exports need those leading parameters — meaning two
export sets in artMain, not one.
Implemented (bindings.preferFastNative = SDK_INT >= 34, androidMain/.../bindings.kt; the
crossover is pinned in the next section, and dispatchPicksTheFasterConventionOnThisDevice re-measures
it on whatever device runs it).
Measured across five API levels plus hardware. Net of the Kotlin floor, ns/call, from
JniOverheadBenchmark:
| API | @CriticalNative |
@FastNative |
ordinary JNI |
|---|---|---|---|
| 26 (Android 8.0) | 1.86 | 38.69 | 45.33 |
| 30 (Android 11) | -0.24 | 31.76 | 73.09 |
| 31 (Android 12) | -0.02 | 24.29 | 45.97 |
| 33 (Android 13) | -0.04 | 2.03 | 7.82 |
| 34 (Android 14) | 24.35 | 1.97 | 7.81 |
| 36 (Android 16, SM-X910 hardware) | 44.05 | 3.55 | 18.32 |
@CriticalNative is effectively free — at or below the measurement floor — from API 26
through 33, then collapses at 34 and stays collapsed on hardware. @FastNative moves the
other way: expensive through API 31, cheap from 33 onward. Two independent ART changes, and
they do not happen at the same release.
The boundary is Android 14, not Android 12. Google's guidance to bind @CriticalNative
through RegisterNatives before Android 12 suggested 31 as the interesting version, and that
turned out to be about correctness, not cost: name-linked @CriticalNative aborts the runtime
below 12 and merely runs slow above it, while the registered form stays fast right through 33.
A real C API call behaves the same as the empty echo. PyList_Size on sys.path costs 2.22ns
under @CriticalNative on API 33 and 47.33ns on API 36, tracking the echo numbers closely.
PyList_Size is an O(1) header read, so the transition dominates and the convention choice is
not academic.
Selection rule, from measurement: SDK_INT >= 34 → @FastNative, otherwise
@CriticalNative. Both are cheap at API 33, so a threshold placed there is forgiving in the
one place the data is closest.
Three separate times, an instrumented run failed because the APK did not contain what the
source tree said it should. connectedDebugAndroidTest does not reliably force
linkAndroidNative* or the copyAndroidPythonBinaries / copyAndroidPythonAssets staging,
so a changed .def or a cleaned build/ produces an APK holding a stale — or entirely
missing — libmultiplatform_python3.14.so and no libpython3.14.so beside it. The symptom is
UnsatisfiedLinkError, which reads like a code error and is not one.
The copy tasks are now wired as dependencies of the assets/jniLibs merge tasks
(python-multiplatform/build.gradle.kts, copyAndroidPythonBinaries / copyAndroidPythonAssets);
I did not verify whether connectedDebugAndroidTest now forces the linkAndroidNative* tasks too.
If an instrumented run fails with UnsatisfiedLinkError, run this first:
./gradlew :python-multiplatform:linkAndroidNativeArm64 :python-multiplatform:linkAndroidNativeX64 \
:python-multiplatform:copyAndroidPythonBinaries :python-multiplatform:copyAndroidPythonAssets
Picking by API level is only half of it. The two fast conventions are not merely faster — they change what the callee is allowed to do:
- Both
@FastNativeand@CriticalNativestop the garbage collector for the duration of the call, so neither may wrap anything that runs for an unbounded time. @CriticalNativeadditionally receives no JNIEnv, so nothing beneath it can re-enter the runtime at all.
Plenty of CPython entry points can execute arbitrary Python: a module's top-level code during
import, a __getattr__ or descriptor during attribute lookup, a __del__ reached by dropping
the last reference, site.py during start-up, atexit handlers during teardown. Those are
unbounded in time, and once Kotlin callables are exposed to Python they re-enter the JVM —
which is exactly what @CriticalNative cannot support. This is the same reason
Linker.Option.critical() is a per-downcall option in Panama rather than a global switch.
So each function is classified, and the classification outranks the API-level choice:
| class | rule | of the 11 migrated |
|---|---|---|
| leaf — cannot execute Python | fastest convention for the API level | Py_IsInitialized, PyList_Size, PyErr_Occurred, Py_GetVersion, PyLong_FromLongLong |
| re-entrant — may run Python, may upcall, may block | ordinary JNI, always | Py_Initialize, Py_Finalize, PyErr_Clear, PyRun_SimpleString, PyImport_ImportModule, PyObject_GetAttrString |
PyErr_Clear looks like a leaf and is not: clearing the error drops the last reference to the
exception, and that can run a Python __del__. PyObject_GetAttrString looks like a field
read and is not, for the same kind of reason. When in doubt the call goes on the ordinary
path — a wrong guess there costs nanoseconds, while a wrong guess the other way is a crash
once upcalls exist.
Losing the fast path on the re-entrant half is not the tragedy it appears. Those calls do real work — importing a module or running a statement dwarfs a 40ns transition — whereas the leaf calls, where the transition genuinely dominates, are exactly the ones that keep it.
The first two tables below are the pre-fix desktop numbers (reflective
invoke); the desktop row is corrected by "Fixed: 1015.95 ns -> 2.65 ns" and "Desktop vs Android, corrected" further down.
Same benchmark shape on both sides: same warmup, same iteration count, best-of-7, a
pure-Kotlin identity call subtracted as the floor, and the same real C API call —
PyList_Size on sys.path.
| floor | PyList_Size |
net | |
|---|---|---|---|
| Desktop (Apple M1, JDK 21, Panama) | 0.33 ns | 1015.95 ns | 1015.61 ns |
Android API 36 (SM-X910, @FastNative) |
3.08 ns | 7.47 ns | 4.39 ns |
Android API 33 (emulator, @CriticalNative) |
2.28 ns | 2.22 ns | ~0 ns |
Desktop is roughly 140x more expensive per call than Android hardware, and the gap is worse than it looks: the host is about 9x faster at the floor (0.33ns vs 3.08ns), so the FFI call is slower on the machine that is faster at everything else.
The assumption running through this document — that Android is the platform with the overhead problem and desktop is the reference — is backwards. Desktop is currently the slowest FFI path in the project by two orders of magnitude.
None of this is inherent to Panama. It is the reflection-based backend:
inline fun PyList_Size(list: Long): Long = PyList_SizeHandle.invoke(list) as Long
invoke rather than invokeExact forces an asType adaptation and boxes the argument and
the result on every call. Worse, the argument and return filters are themselves reflective
MethodHandle.invoke calls that build a MemorySegment per call via ofAddress and
reinterpret. A microsecond for an O(1) header read is what that chain costs.
The backend was written reflectively so the code would compile against java.lang.foreign
and jdk.incubator.foreign on any JDK, which was the right call for portability and the
wrong one for the hot path. The portability requirement does not extend to the per-call path
— only to how handles are created.
invokeExact was flagged as a concern long before this measurement and never quantified.
It is now: about 1000ns per call, against roughly 5-10ns for a properly linked Panama
downcall.
This makes desktop, not Android, the first thing to fix. The shape vocabulary already
committed is the lever: 14 fixed signatures make invokeExact reachable, because the call
site's static type is then known and constant. That work now has a measured payoff rather
than a suspected one.
Two changes, both confined to the desktop backend:
Pointers are now described as ValueLayout.JAVA_LONG, not ADDRESS. Both are 8 bytes and
travel in the same register on a 64-bit ABI, but describing them as addresses forced Panama to
hand back a MemorySegment, which the backend then unwrapped through per-call argument and
return filters -- reflective MethodHandle invocations of ofAddress, reinterpret and
getString. Describing them as longs deletes the filters outright.
Call sites use invokeExact. With no filters the handle's type is exactly
(long, long, ...) -> long, which matches the Kotlin call site's static type, and that is the
condition invokeExact requires. Kotlin accepted it directly with no signature-polymorphism
workaround, which had been the open question. 265 wrappers now use it.
Measured on the same machine, same benchmark:
| before | after | |
|---|---|---|
PyList_Size on sys.path |
1015.95 ns/call | 2.65 ns/call |
| net of the Kotlin floor | 1015.61 ns | 2.01 ns |
About 380x, and desktop goes from the slowest FFI path in the project to the fastest — 2.65ns against 7.47ns on Android hardware, on a host roughly 9x faster at the floor, so the two are now in the same regime rather than separated by two orders of magnitude.
The reflective two-backend selection is untouched and still resolves java.lang.foreign or
jdk.incubator.foreign at startup. That was never the problem; letting reflection reach the
per-call path was. Reflection now runs once per symbol, at link time.
Strings still convert at the Kotlin level rather than through a handle filter: withUtf8 { }
allocates, passes the address as a long, and frees in a finally. Return values owned by
CPython are read without being freed, since freeing them would be a use-after-free.
Until now the modern-Android numbers came from a Samsung tablet while the older ones came from emulators on the M1 host, so "API 36 is slowest" mixed two variables. Running API 36 on an emulator settles it.
Same API level, different machine — real PyList_Size call:
| API 36 | floor | @CriticalNative |
@FastNative |
ordinary JNI |
|---|---|---|---|---|
| emulator (M1 host) | 2.20 ns | 23.20 | 4.28 | 9.39 |
| hardware (SM-X910) | 3.22 ns | 46.75 | 7.24 | 19.25 |
Hardware is 1.5-2x the emulator across every row, and the floor moves by the same factor (3.22 vs 2.20). That is the tablet's core being slower than the host, not anything about API 36. The earlier reading that "API 36 is the slow one" was partly this artefact.
Same machine, different API level — emulators on the M1 host only:
@CriticalNative |
@FastNative |
|
|---|---|---|
| API 26 | 1.85 | 40.15 |
| API 33 | -0.04 | 2.03 |
| API 36 | 21.72 | 1.66 |
The collapse is still real and still API-driven: @CriticalNative goes from free at 33 to
21.72ns at 36 with the hardware held constant. So the crossover conclusion and the dispatch
threshold stand — hardware exaggerated the gap but did not create it.
The earlier "desktop 2.65ns vs Android 7.47ns" compared an M1 host against a tablet. On the same host, for the same real call:
PyList_Size |
|
|---|---|
Desktop (Panama, invokeExact) |
2.65 ns |
Android API 36 emulator (@FastNative) |
4.28 ns |
Android API 36 hardware (@FastNative) |
7.24 ns |
About 1.6x between the two paths on identical hardware, not the 2.8x the cross-machine comparison implied. Both are now in the same regime, which is the part that matters.
Per-crossing cost has bottomed out — 2.65ns on desktop, 1.66-7.24ns on Android depending on
API level and hardware. The only lever left is crossing fewer times, which is the structure
the Android path was originally built around: artMain assembling a whole binder operation
and androidMain calling that assembly once, rather than commonMain driving raw C API
calls one crossing at a time.
CompositionBenchmark measures two cases against their per-call equivalents.
| per-call | composed | ||
|---|---|---|---|
| API 36 hardware | |||
getAttr (4 crossings → 1) |
3929.84 ns | 713.34 ns | 5.5x |
list → LongArray, 1000 elems (1001 → 1) |
50065.89 ns | 4532.55 ns | 11.0x |
| API 36 emulator | |||
getAttr |
2270.23 ns | 474.67 ns | 4.8x |
list → LongArray |
28145.00 ns | 3705.63 ns | 7.6x |
| API 26 emulator | |||
getAttr |
2431.34 ns | 244.70 ns | 9.9x |
list → LongArray |
4890.83 ns | 4113.13 ns | 1.2x |
Composition wins, but not purely by removing crossings. Four crossings at ~7ns is 28ns,
which does not explain 3929ns versus 713ns. The per-call getAttr allocates a C string
through ffiAllocUtf8 — a malloc, a copy out of the jstring, and a matching free — where the
composed version borrows the jstring directly with GetStringUTFChars and releases it. Most
of the win is redundant work that disappears when the operation is expressed once on the
native side, and the crossing count is the smaller part. That is still a real argument for
composing, but the mechanism is not the one the crossing-count framing suggests.
The bulk case is where crossing count genuinely dominates, and it scales with N: 11x at
1000 elements on hardware. That is the case the current design handles worst, because
commonMain drives it element by element.
API 26 barely benefits from composing the bulk case — 1.2x. Its per-element crossings run
under @CriticalNative, which is free at that API level, so there was little to remove. This
is the mirror image of the convention result: composition pays most exactly where the
per-crossing cost is highest, which is modern Android, which is where the users are.
PyObject was expect/actual with an Android actual marked external — the hook for
exactly this — until commit 0fae961a (2025-12-20) folded it into a single commonMain class.
That commit's real subject was moving PyAutoCloseable to per-platform implementations, which was
right and should stand; losing the composition hook was collateral. The resolution was not to
restore expect/actual on PyObject: composition lives in the FFI layer (architecture.md), and
for string-carrying operations interning replaced it (marshalling-design.md).
Windows cannot host a Kotlin/Native assembly library, so a composed desktop path would cover macOS and Linux only, with Windows staying on direct Panama calls. At 2.65ns per crossing that is a much smaller loss on desktop than the same gap would be on Android.
The upcall side has §7.1 "One upcall, per platform" in upcall.md — one shared
test, run on every target, min–max over several runs, quoted rows marked as quotes. The downcall
side had no equivalent: the benchmarks that exist are real and each one answers a question, but
they are scattered across five files with no shared shape, so "how expensive is a downcall on
platform X" had no single table to read.
| File | Source set | What it measures | Runs where |
|---|---|---|---|
commonTest/.../overhead/Benchmark.kt |
commonTest |
Warmup-then-time harness (run/measure), not a benchmark itself |
wherever the tests below run |
commonTest/.../overhead/BenchmarkTest.kt |
commonTest |
Pointer boxing, raw refcount churn, string marshalling (PyUnicode_FromString/AsUTF8, three lengths), integer marshalling, PyObject wrapper cost, PyObject_GetAttrString |
compiles for all six run paths below; confirmed green on desktop / iosSimulatorArm64 / wasmJsNode when the table was first cut; reaches androidInstrumented and androidNativeArm64/X64 through the same commonTest dependency edge but neither ran here (no emulator this session) |
desktopTest/.../DesktopOverheadBenchmark.kt |
desktopTest |
Panama transition cost (PyList_Size net of a pure-Kotlin floor); the string-marshalling-share-of-exec test is @Ignored (destabilises the interpreter, see the file's own comment) |
desktop only |
wasmJsTest/.../WasmMarshallingOverheadTest.kt |
wasmJsTest |
internedUtf8 vs scratchUtf8 vs the old malloc/copy/free route, at two string lengths, both standalone and through a real PyObject_GetAttrString |
wasmJs only |
androidInstrumentedTest/.../JniOverheadBenchmark.kt |
androidInstrumentedTest |
JNI calling-convention transition cost: ordinary / @FastNative / @CriticalNative, on a trivial echo and on PyList_Size |
Android/ART only, needs a device or emulator |
androidInstrumentedTest/.../CompositionBenchmark.kt |
androidInstrumentedTest |
Per-call crossings vs one composed artMain call, for getAttr and list → array, plus a direct-buffer marshalling variant |
Android/ART only, needs a device or emulator |
Two more files live next to these and share the word "overhead" or "Upcall" in their name but are
not downcall benchmarks — noted here only so a future reader does not double-count them:
commonTest/.../reflection/UpcallOverheadTest.kt prices the upcall dispatch table
(UpcallTable.resolve vs a cached handle), and androidInstrumentedTest/.../UpcallOverheadTest.kt
prices the Android upcall attach. Both are upcall-side and already covered by upcall.md.
BenchmarkTest reaches every one of the six execution paths KGP registers for this module
(desktopTest, iosSimulatorArm64Test, iosX64Test, wasmJsNodeTest, androidInstrumentedTest/
connectedAndroidTest, androidNativeArm64Test+androidNativeX64Test) because it sits in
commonTest, which every one of those depends on transitively. It is confirmed to run — not just
compile — on three of the six this pass: desktop, the iOS simulator, and wasmJs (see Validation
below). iosX64Test has no practical run path on Apple Silicon and nobody targets it; the
Android/ART and androidNative rows need a connected device or emulator, which this pass did not
have available.
The upcall table's denominator column is PyObject_CallObject on a Python def — "a downcall of
the same shape" — plus Py_IncRef/Py_DecRef and an empty withPython scope as the two
boundary-cost brackets. Those three, plus one string-marshalling call, are exactly what
UpcallBoundaryCostTest's measureDowncalls() (commonTest) already records in the same run as
the upcall it prices — so pulling them out as their own table is not a new measurement, it is the
existing "comparison basis" rows read on their own. The fourth column, string marshalling, comes
from BenchmarkTest.testStringMarshalling's 8-character row, run in the same suite execution.
Desktop, iOS simulator and wasmJs are measured fresh here — three full-suite runs
(:desktopTest :iosSimulatorArm64Test :wasmJsNodeTest), same command as Validation below, each
with build/test-results cleared first so a task cannot report a stale XML. All three runs matched
the baseline exactly (360/0/1, 330/0/0, 340/0/0). Ranges are min–max over three runs for the first
three columns; the string-marshalling column is min–max over two of those three runs (the first
run's BenchmarkTest XML was not preserved before the second run overwrote it — its
UpcallBoundaryCostTest numbers were captured from console output before that happened, which is
why that row has three runs and this one has two).
androidNative and Android/ART rows are quoted from upcall.md, not re-measured — marked
† below, same convention that document uses for its own quoted rows. Cells with no prior
measurement of this exact quantity are left blank rather than approximated from a different one
(e.g. CompositionBenchmark's composed getAttr is a different quantity — four crossings folded
into one — not a bare PyUnicode_FromString).
Four of this table's seven rows are quotes from another document and one column of it is superseded (below), which is what happens to a table assembled by hand. The same four columns, cut for all six targets in one run with their conditions attached, are in
cost-table.md:./benchmarks/cost-table.sh --runs 3on a quiet machine fills it. It draws the first three columns from the samemeasureDowncalls()rows this table does and the fourth from the sameBenchmarkTestrow, so it is this table's recipe automated, not a different measurement.
| Platform | empty withPython scope |
Py_IncRef + Py_DecRef |
PyObject_CallObject (downcall, same shape as the upcall table's numerator) |
PyUnicode_FromString, 8 chars |
|---|---|---|---|---|
| desktop (JVM 21.0.12, macOS arm64) | 51.78–52.60 ns | 250.48–257.99 ns | 260.27–265.81 ns | 185.13–191.00 ns |
| iOS simulator (arm64) | 701.18–723.85 ns | 1488.64–1543.92 ns | 1585.46–1655.40 ns | 3338.51–3490.55 ns |
| wasmJs (Node) | 24.66–25.15 ns | 45.23–46.98 ns | 96.53–100.95 ns | 101.55–103.22 ns |
androidNative pmp_api36† |
998.73–1051.08 ns | 2058.84–2167.44 ns | 2194.26–2365.61 ns | 4419.55–4706.72 ns |
androidNative pmp_api26‡ |
1193–1298 ns | (no prior measurement) | 2636–5554 ns | (no prior measurement) |
| Android API 26 (ART)† | (no prior measurement) | (no prior measurement) | 1253.41–1295.79 ns | 1404.00–1485.24 ns |
| Android API 36 (ART)† | (no prior measurement) | (no prior measurement) | 804.27–838.60 ns | 1013.04–1197.93 ns |
Source for rows 1–4, 6–7: docs/investigations/cost-table.md (commit 958c0082b294), UpcallBoundaryCostTest columns 1–3 with warmup 100,000, min–max over 3 runs per target. Column 4 (PyUnicode_FromString, 8 chars) from BenchmarkTest same source, warmup 100,000.
† Rows 1, 2, 3, 4 and 6–7: Directly from cost-table.md. The Android/ART rows (6–7) record both upcall and downcall in the cost-table measurement, fixing the prior "not recorded" state.
‡ androidNative pmp_api26: Does not appear in cost-table.md (only androidNativeArm64 on pmp_api36 is there). Awaiting re-measurement under the same conditions as the cost-table harness.
Prior values (warmup 3,000): desktop empty scope 123.88–135.99 ns, Py_IncRef+Py_DecRef 321.69–334.10 ns, PyObject_CallObject 315.69–335.32 ns, PyUnicode_FromString 1129.95–1270.76 ns (note: this final column was superseded separately, see below).
The
PyUnicode_FromString, 8 charscolumn has been updated. It is sourced fromoverhead/BenchmarkTestwith warmup now 100,000 (was 100), for the reason "The benchmark was measuring the benchmark" explains inupcall.md. The old figures here were 3–7x the warmed cost because they were swept at the old100-call warmup on desktop (1132–1146 ns cold vs 167–458 ns warm). The new column records the current cost at 100,000 warmup, from commit958c0082b294. The first three columns also come fromUpcallBoundaryCostTest(already fixed at 100,000 warmup since7e9c6b8c), so all four columns are now consistent in warmup and source.
The first version of the shared table was taken with UpcallBoundaryCostTest's 3 000-call warmup,
and its absolute upcall figure then drifted between commits (desktop 861 ns → ~690 ns, wasmJs
~490 ns → ~310 ns) without any change to the timed code. The investigation (full record in
../archive/downcall-design-benchmark-drift.md)
established:
- The figure was a same-process warm-up artefact, not a boundary cost. Anything that pushed
upcall traffic through the shared JVM / Node process earlier in the suite (a proxy cost test
starting to install proxies; one commit on wasmJs,
4472f83a) moved the number by 25% on desktop and ~35% on wasmJs. Confirmed by direct experiment (short-circuitingGeneratedProxyCostTestmoved it back). - It is host JIT tier-up (C2 on the JVM, V8 on wasm), not CPython-side state: the pure-Python rows (empty loop 8–9 ns desktop; callee 24–27 ns) are flat from the first repetition, only rows that cross into host code move. 40 repetitions plateau at ~535 ns (desktop upcall), ~141 ns (desktop Kotlin-driven downcall), ~280 ns (wasmJs upcall).
- Convergence needs 40 000 calls cold and ~70 000 warm, so the warmup is 100 000 (and
GeneratedProxyCostTest5 000 per row, because it shares_pm_invokeacross 19 rows). Cost: about +0.2 s on desktop (+4% of suite time). - With 100 000 warmup the "proxy test short-circuited" lever no longer moves desktop (501–530 vs 510–560 ns) or wasmJs (287–301 vs 290–304 ns) outside run-to-run noise. That is the fix.
- On wasmJs the absolute column still does not survive filtering (
--teststhis class alone: pure Python rows 55–74% slower, because a short-lived Node process runs CPython-under-V8 colder); the ratio columns do (2.66–3.13x solo vs 2.91–3.14x in the suite). Quote wasmJs absolutes as full-suite figures. inlineonBenchmark.measurewas tried and is worse (needs ~230 000 iterations for the same plateau). Do not retry.- Rules that follow: a measurement is full suite, single task, quiet machine (
uptimefirst); filtering with--testschanges the measured region; record the suite composition with the number; never run two measurement sessions concurrently (load 10–12 made one commit read 672–1076 ns).
Each platform-only file above measures something the others cannot, and none of it collapses into the four shared columns:
- Desktop: the Panama
invoke→invokeExactmigration (DesktopOverheadBenchmark, 1015.95 ns → 2.65 ns forPyList_Size) is a desktop-only story — no other platform has an equivalent adaptation-vs-exact distinction. - Android/ART:
JniOverheadBenchmark's ordinary/@FastNative/@CriticalNativecomparison andCompositionBenchmark's per-call-vs-composed figures are bothartMain/androidMainconcepts with no analogue on a target that has no separate calling convention to choose between. - wasmJs:
WasmMarshallingOverheadTest's interned-vs-scratch-vs-malloc string routes are a wasm-linear-memory concept; the other platforms marshal strings through Panama or JNI, not through an explicit intern cache.
These are not merged into the shared table, and the files are not merged or deleted — the request for this pass was a table, not a consolidation, and each file's platform-specific framing (its own warmup counts, its own comparison baselines) is part of what makes its own number meaningful.
Three separate instrumented runs failed with UnsatisfiedLinkError on symbols that were
demonstrably present in the freshly linked .so. The cause was in build.gradle.kts:
linkTaskProvider.configure {
copy { from(outputFile); into(...) } // runs at CONFIGURATION time
}
A bare copy {} inside a configure {} block executes while Gradle is configuring the build,
not when the link task runs — so every build staged the previous build's library into
jniLibs. Any change to the .def or to native sources needed two full builds before it
reached the device, and in between the failure looked exactly like a code bug. Moved into
doLast, so it stages what the link actually produced.