How Python code imports Kotlin packages, constructs Kotlin classes, calls their functions (including
suspend ones) and reads their properties. The opposite direction is
downcall-design.md; what is exposed at all is
binding-policy.md; object lifetimes and cycle collection are
object-lifetime.md; thread state is threading-and-abi.md.
This is a design record. The behavioural contract is SPEC.md §5 (U-1 … U-8) and
the boundary is INTENT.md §2.1, §2.2, §2.6; where this document and those disagree,
they win. It replaces three earlier documents (upcall-design.md, upcall-table-design.md,
upcall-async-design.md); their history is in git.
What is implemented, in one paragraph: a KSP processor generates a function table for every public
declaration of a module at build time (U-1); one entry point per platform marshals arguments into it
(U-2); generated Python proxies make Kotlin packages importable under their own names with classes,
properties, companions and statics (U-3, U-4); suspend functions are awaitable (U-5); upcalls work in
a GraalVM native image (U-6, manually verified); every function on a Kotlin-named module carries Kotlin's
own surface (U-8). It runs on desktop, Android (ART), iOS, androidNative and wasmJs, with the exceptions
listed in §8.
Conventions used below:
- Measured means it was run in this repository and the test that prints or asserts it is named. Inferred means it was read from code or documentation and not run. (unverified) marks a statement this consolidation could not check against code or tests.
- Every measurement carries its conditions (platform, warmup, run count, commit where recorded). Nothing in the test suite asserts a duration: wall-clock thresholds on a shared machine, an emulator or a Node host are flake generators. Tests assert structure (the call reached Kotlin, the figure is positive) and print the numbers.
- The authoritative, harness-cut cost figures are
cost-table.md(./benchmarks/cost-table.sh --runs 3). Hand-cut tables here are records of the round they were taken in; prefer the cost table where they disagree.
Two reasons, the second more fundamental:
- GraalVM native image is closed-world: runtime reflection does not work without registration.
- Kotlin/Native has effectively no reflection (
::class.simpleName, no member enumeration). iOS and androidNative have no reflective path regardless of GraalVM.
A multiplatform library therefore has no option but a table generated at build time (AGENTS.md §12.3, INTENT §2.1). The same choice is what makes cycle collection possible (§6).
Objective-C is fast not because it has a table but because a selector is an interned pointer:
objc_msgSend finds the IMP in the class's method cache in a few instructions without touching a string.
The equivalent here:
| When | What happens |
|---|---|
| Once per name | Python passes a name ("demo.Counter.increment"); Kotlin resolves it in the table and returns a CallableHandle |
| Every call after that | The handle crosses, never the string |
Passing a string per call costs PyUnicode → UTF-8 (an allocation) + the boundary + hashing + comparison —
hundreds of ns — whatever the table looks like. UpcallTable.resolve returns a CallableHandle whose raw
value packs the table epoch (high 32 bits) and the index (low 32), so a handle cached across a table
reinstall is rejected rather than calling a different function
(python-multiplatform/src/commonMain/kotlin/python/multiplatform/reflection/ExposedCallable.kt,
UpcallTable.kt). The in-Kotlin part of the comparison is measured by
commonTest/.../reflection/UpcallOverheadTest.kt (testNameResolutionVersusCachedHandle, 200-entry
table); that test's numbers are a lower bound on the gap because the boundary is not in them.
A table entry is a generated lambda, not a KFunction: KFunction.call re-checks arity and types and
boxes on every call (50–100 ns, measured in this project's benchmarks) and does not exist on
Kotlin/Native, while { args -> greet(args[0] as String) } compiles to a direct call there and gives the
linker a static reference (§2.6).
Every CPython callback convention passes the relevant object as an argument (checked against the headers):
| Slot | Signature | What identifies the target |
|---|---|---|
PyCFunction |
(PyObject *self, PyObject *args) |
self |
destructor |
(PyObject *self) |
self |
getter / setter |
(PyObject *self, void *closure) |
the explicit closure |
initproc |
(PyObject *self, args, kwargs) |
self |
newfunc |
(PyTypeObject *type, args, kwargs) |
type |
So routing is done with data, not function pointers: a handful of shared trampolines serve an
unbounded number of Kotlin targets, and no runtime code generation (LLVM JIT, libffi closures) is
needed. JPype, PyO3 and JEP all work this way. The only case that would need runtime code generation is a
pure C callback with no context argument at all (a qsort comparator); the CPython object model has none.
Argument passing needs exactly one carrier shape (§3.2), already inside the downcall vocabulary.
Behind the per-platform entry point every platform uses the same generated table and the same
commonMain trampoline (UpcallTrampoline); a platform contributes only the few lines that give Python
the address of an entry point (§3.7). INTENT §2.6.
Every public declaration is registered; @PythonInternal opts a class or member out. A whitelist
(@PythonAPI) was rejected (AGENTS.md §12.7; binding-policy.md). This is the
premise the tree-shaking decision (§2.6) rests on and must not be re-litigated there.
A Kotlin package is importable under its own fully-qualified name and nothing else. Functions on a
Kotlin-named module take keyword arguments by Kotlin parameter names, honour Kotlin defaults for
omitted parameters, and expose their signature through inspect.signature and
python_multiplatform.describe (SPEC U-8; contract in the KDoc of
python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/KotlinSurface.kt). Nothing is
renamed: no snake_case, no synthetic pythonx module (INTENT §2.2–2.3, AGENTS.md §12.1–12.2). Making the
surface Pythonic is pythonx-compose's job, built on this metadata. See §4.3.
The KSP processor (python-multiplatform-ksp/) is shipped as an artifact and runs in every module that
exposes Kotlin to Python — including the user's own modules, since exposing the user's classes is the
point. Each module emits one fragment object into the well-known package
python.multiplatform.generated.fragments:
// GENERATED by python-multiplatform-ksp. Do not edit.
package python.multiplatform.generated.fragments
object Fragment_io_github_thisisthepy_ksp_fixtures_library : python.multiplatform.reflection.FunctionTableFragment {
override val moduleName: String = "io_github_thisisthepy_ksp_fixtures_library"
override fun entries(): List<ExposedCallable> = listOf(
ExposedCallable(name = "fixture.library.greet", arity = 1, paramTypes = listOf(TypeTag.STRING),
returnType = TypeTag.STRING, paramNames = listOf("name"), callable = { args -> fixture.library.greet(args[0] as String) }),
// ...
)
override fun classes(): List<ReflectedClass> = listOf(/* ... */)
}(Shape from SourceRendering.kt's renderFragmentSource; abbreviated.)
- Name:
Fragment_<moduleName>, wheremoduleNameis the KSP optionpython.multiplatform.moduleNamewith.and-replaced by_(Constants.kt). The Gradle plugin derives it from the Maven group plus the project path (:ksp-fixtures:appwith groupio.github.thisisthepy→io_github_thisisthepy_ksp_fixtures_app), because fragments from every artifact share one package and two libraries both called:corewould otherwise collide. The name is not load-bearing for discovery (the whole package is scanned) but prevents collisions. - Visibility:
public. Aninternalfragment does not work:internalis enforced per Kotlin module, and the app module's aggregator referencing a library's fragment is another module even inside one Gradle build ("cannot access … it is internal"). - Each
ExposedCallablecarries the Python-visible qualified name,arity, aTypeTagper parameter and for the return, aCallableKind(where the arguments are:FUNCTION,CONSTRUCTOR,METHOD,GETTER,SETTER,STATIC_GETTER,STATIC_SETTER),isSuspend(§5.3), the KotlinparamNames, declared type names, extension/receiver data,paramHasDefault, and the lambda.ReflectedClassentries carry class metadata and, for classes withPyObject-typed properties, a generatedtp_traversebody (§6). - The library's own packages (
python.native.ffi,python.multiplatform.ffi,…reflection,…ref) and the generated packages are always excluded;python.multiplatform.excludePackagesadds more.
| Declaration | Exposed | Notes |
|---|---|---|
public top-level functions |
yes | main() with no parameters or one Array<…> is excluded (BindingPolicy.isExposedTopLevelFunction) |
public top-level properties |
yes | module attribute: STATIC_GETTER / STATIC_SETTER |
public classes + constructors |
yes | constructor only if concrete and non-inner (isConstructible) |
public member functions |
yes | |
public properties |
yes | getter; setter only for a var whose setter is itself public (private set / internal set are read-only from Python, isExposedSetter) |
companion object members |
yes | under the owner's name, no receiver |
object declarations |
yes | same shape, no constructor |
interface |
yes | members only; not constructible; receiver arrives as a handle |
enum class |
yes | one STATIC_GETTER per entry, plus name/ordinal/valueOf |
| nested classes / interfaces / enums / objects | yes | under Outer.Inner; the walk stops at a non-exposed owner |
suspend functions |
yes | body wrapped in PendingCall.start { }, isSuspend = true (§5.3) |
annotation class |
no | reading one back needs runtime reflection; an instance has nothing to attach to |
enum values() / entries |
no | return collections; ReflectedClass.enumEntryNames carries the same data |
generic declarations (class Box<T>, fun <T> f) |
no | args[0] as T does not compile |
| abstract / sealed class constructors | no | "Cannot create an instance of an abstract class" |
internal / private / protected |
no | |
| extension functions / extension properties | no (KSP) | the artifact walker does bind extensions, as methods on the receiver's proxy (kotlin-extensions-in-python.md; SPEC U-7 is partial and not asserted for KSP-generated proxies) |
@Composable functions in the module's own source |
no | a generated non-composable lambda cannot call one; the artifact walker binds composables instead (BindingPolicy.isComposable KDoc) |
declarations whose type is a suspend function type, or one nested in a generic argument |
no, with a KSP warning | "has a type with no exposable classifier" (FragmentScanner.warnUnexposableType; GeneratedSuspendTest) |
compiler-generated copy, componentN |
no | the subset KSP reports for a data class (no equals/hashCode/toString) |
expect declarations |
no | the actual is found on the platform side |
@PythonInternal |
no | opt-out |
Asserted against KSP-generated fragments, not hand-written ones:
ksp-fixtures/app/src/desktopTest/.../GeneratedDeclarationKindsTest.kt,
GeneratedSuspendTest.kt, GeneratedTableTest.kt (which runs UpcallTableTest's scenarios — constructor
/ method / getter / setter round trip through HandleTable, @PythonInternal exclusion, narrow
Int/Float widening, tp_traverse field detection — against a generated table), and
python-multiplatform-ksp/src/test/.../SourceRenderingTest.kt.
Kotlin/Native has no ServiceLoader; a top-level object that nothing references is never initialised
(lazy initialisation is the default since K/N 1.7.20); and reflection cannot enumerate. So a fragment that
registers itself in its own initialiser never runs. Fragments must be referenced explicitly by something
reachable.
The app-role module's KSP therefore generates an aggregator that names every fragment:
package python.multiplatform.generated
object FunctionTable {
val fragments: List<FunctionTableFragment> = listOf(
python.multiplatform.generated.fragments.Fragment_my_library,
python.multiplatform.generated.fragments.Fragment_my_app,
)
fun installInto() { python.multiplatform.reflection.UpcallTable.install(fragments) }
}(renderAggregatorSource.) UpcallTable.install clears the table (bumping the epoch) and registers each
fragment; a fragment already registered under the same moduleName is a no-op, and a name claimed by two
different fragments is an error, detected before anything is mutated (SPEC U-1).
Discovery uses resolver.getDeclarationsFromPackage("python.multiplatform.generated.fragments"), in two
KSP rounds:
- Round 1 — generate the app's own
Fragment_<app>. The new file triggers round 2. - Round 2 —
getDeclarationsFromPackagenow sees the app's fresh fragment and every dependency's fragment, which is on the compilation classpath as compiled classes (JAR) or klib metadata. GenerateFunctionTable.
KSP cannot see other modules' sources, but this query reads the classpath, which is why it works. The key
is the well-known package, not an annotation: getSymbolsWithAnnotation is documented as potentially
expensive because it scans the whole classpath; the package query is cheap. (A marker-interface filter on
FunctionTableFragment would also work; the package is the primary mechanism.)
getDeclarationsFromPackage is @KspExperimental. It is the documented cross-module API, nothing
non-experimental does the job, and it has been stable since KSP 1.x. Discovery sits behind the
FragmentDiscovery interface (FragmentDiscovery.kt) so a fallback — each module writing a metadata file
under META-INF/, read by the aggregator — can replace it without changing the fragment or table shape.
The same mechanism serves every target:
| Platform | Discovery source | Status |
|---|---|---|
| JVM desktop | JARs on the classpath | ksp-fixtures/app desktop tests |
| Android | JAR/AAR classpath | ksp-fixtures/android (GeneratedAndroidTableTest, unit and instrumented) |
| GraalVM native image | classpath at build time; the table is statically reachable, so no reflection config | verified manually, graal-native-image-verification.md (SPEC U-6) |
| Kotlin/Native | klib metadata | ksp-fixtures/{library,app} on androidNativeArm64: round 2 found the library's fragment in its .klib, FunctionTable compiled and linked, and ksp-fixtures/app/src/androidNativeArm64Test/.../NativeSmokeTest.kt runs it (SPEC U-3) |
ServiceLoader could work on JVM/Android, but one aggregator everywhere avoids a platform split and runtime
classpath scanning.
FunctionTable is emitted into each leaf compilation, so only that target's own source set can name it;
an intermediate source set (commonMain, iosMain, androidNativeMain) gets
Unresolved reference 'FunctionTable'. Generating in kspCommonMainMetadata instead would fix visibility
and break the scan (a common-only scan sees only common declarations). So generation stays in the leaves
and the seam is generated:
// commonMain
@InstallsUpcallTable
expect fun installGeneratedUpcallTable()The app-role processor finds the annotated expect (a leaf compilation resolves its whole source-set
closure) and writes one actual per leaf compilation into the expect's package:
@python.multiplatform.reflection.PythonInternal
actual fun installGeneratedUpcallTable() { python.multiplatform.generated.FunctionTable.installInto() }@PythonInternal because the actual is a public top-level function in the user's package and an
incremental round could otherwise expose "reinstall the table" to Python. A library-role module carrying
the annotation is an error (it aggregates nothing). expect/actual rather than an interface plus a
runtime registry, because the reference chain must stay static or the Kotlin/Native linker may drop what
nothing names. The seam's shape is checked in InstallSeam.kt (top-level, no parameters, returns Unit,
public or internal).
One SymbolProcessorProvider (PythonBindingProcessorProvider.kt) with two roles:
python.multiplatform.role |
Does |
|---|---|
library |
scan the module, emit its fragment |
app |
emit its fragment (round 1), discover all fragments and emit FunctionTable and the @InstallsUpcallTable actuals (round 2) |
| KSP option | Required | Meaning |
|---|---|---|
python.multiplatform.role |
yes | library or app |
python.multiplatform.moduleName |
yes | sanitised fragment name |
python.multiplatform.excludePackages |
no | comma-separated packages to skip, on top of the always-excluded ones |
The Gradle plugin is built: python-multiplatform-gradle-plugin/, id
io.github.thisisthepy.python.multiplatform.bindings. Applying it applies KSP, adds the processor to every
target's main KSP configuration (never a …Test one and never kspCommonMainMetadata, either of which
would emit a second fragment under the same name), infers the role (application /
com.android.application → app, else library; override with pythonBindings { role.set("app") }) and
derives moduleName. Notes from building it:
- It does not compile against KSP's Gradle plugin.
kotlin-dslbuilds against Gradle's embedded Kotlin (2.0.20 on Gradle 8.11.1) and KSP 2.3.11 is compiled with Kotlin 2.3 ("compiled with an incompatible version of Kotlin"). The dependency isruntimeOnlyand theksp { arg(...) }calls go through one reflective lookup ofarg(String, String). - Test configurations are matched as a camel-case word
Test. AGP puts the build type last (kspAndroidTestDebug), so anendsWith("Test")check once put the processor on a unit-test compilation, which emitted a duplicate fragment shadowingmain's.ksp-fixtures/androidcarries the Android plugin for this reason (ROADMAP §13).
If the aggregator references every fragment and every fragment references every public callable, the
linker sees the entire public API as reachable. Each lambda { args -> greet(args[0] as String) } is a
static reference to greet; a reachable fragment makes every target reachable. Function-level DCE
(-ffunction-sections + --gc-sections) cannot help, because the references are real. Three things
cannot all hold: (1) blacklist exposure, (2) a full table referenced at build time (required on
Kotlin/Native), (3) tree shaking of unused entries.
Chosen: give up (3) — accept the cost, because (1) is settled (§1.5) and (2) is forced by §2.3. The remaining options, one line each:
| Option | Verdict |
|---|---|
| B. Finer fragments (per file/class) | Does not help alone: FunctionTable still references every fragment unconditionally; by lazy still captures the reference. (Per-file fragments are still the lever for incremental KSP, §2.7.) |
C. Opt-in manifest (python_bindings.txt) |
A different product: contradicts the blacklist and the "write Kotlin, import from Python" promise |
D. ServiceLoader on JVM, aggregator on Native |
A platform split for no gain: the JVM does not tree-shake anyway |
| E. Two-tier table (skeleton + lazily initialised body) | Saves runtime lambda objects, not binary size: the when arms still statically reference every target on Kotlin/Native |
Measured. ksp-fixtures/library got 200 synthetic top-level functions (fun bulkN(x: Long): Long = x + N);
ksp-fixtures/app's androidNativeArm64 debug test binary was linked before and after
(linkDebugTestAndroidNativeArm64) and stripped with the NDK's aarch64-linux-android-strip:
| bytes | delta | |
|---|---|---|
| baseline (unstripped) | 7,578,480 | |
| +200 entries (unstripped) | 8,343,184 | 764,704 (≈3.8 KB/entry) |
| baseline (stripped) | 3,264,056 | |
| +200 entries (stripped) | 3,632,696 | 368,640 (≈1.84 KB/entry) |
This is a floor: each function is a one-line expression, so almost all of the 1.84 KB is the
ExposedCallable, its lambda, its name string and Kotlin/Native per-function metadata. A real library adds
its function bodies on top. For 200 entries the floor is ≈11% of a 3.26 MB stripped baseline — noticeable,
not disqualifying. Whether that is acceptable for a real library is a product judgement; the only real
mitigation would be changing the exposure model, which is settled.
Dependencies(false) would never regenerate the aggregator and miss a newly added fragment;
Dependencies.ALL_FILES is correct but reprocesses on every change. A narrower declaration buys nothing.
Measured with ksp.incremental.log=true on :ksp-fixtures:app (two files), modifying one:
aggregator Dependencies |
dirty / all |
|---|---|
ALL_FILES |
100.00% |
Dependencies(aggregating = true, <generated fragment files>) |
100.00% |
A module's own Fragment_<module> is an aggregating output over every source file — correctly, since under
blacklist exposure any file can add an entry — so any change regenerates it and KSP marks every source that
maps to it dirty. A classpath-only change behaves the same (adding a function to :ksp-fixtures:library
dirtied both app files with CP changes listing Fragment_ksp_fixture_library). The only route to real
incrementality is per-file fragments, which changes fragment naming, UpcallTable's per-module
idempotency and where duplicate names are detected — a design change, not done.
| Alternative | Why not |
|---|---|
@EagerInitialization |
Deprecated and slated for removal; does not answer who calls register(); defeats tree shaking by definition |
Resource files under META-INF/ |
Not needed while getDeclarationsFromPackage works; more complex (classpath I/O) and less type-safe. Kept as the fallback behind FragmentDiscovery |
Manual FunctionTable.register(Fragment_x) in main() |
Contradicts "apply the plugin and nothing else"; every new dependency needs a call |
| A compiler (IR) plugin | Harder to write and maintain, less stable API, and KSP suffices |
- Transitive dependencies (app → libA → libB with a fragment in libB): should work because transitive classes are on the compilation classpath. (unverified)
- Published Maven artifacts consumed as
implementation("g:a:v")rather thanproject(...): should work the same way. (unverified)
Kotlin, once: UpcallBootstrap.publishToGlobals() publishes _pm_resolve / _pm_invoke / _pm_bind* / _pm_release / _pm_cancel
installGeneratedUpcallTable() -> FunctionTable.installInto() -> UpcallTable.install(fragments)
PythonProxySource.install() renders Python from UpcallTable + ClassLookup, exec's it
Python: from demo.calc import Counter sys.modules hit -- no import hook
c = Counter(10) proxy __init__ -> _pm_invoke(handle, (10,)) -> UpcallTrampoline.invoke
c.increment(5) _pm_invoke(method_handle, (c._pm_handle, 5))
(* _pm_bind is published by the PyMethodDef bootstraps, not by desktop's.) Names are resolved to
handles when the generated module is executed; the calls afterwards carry only handles (§1.2).
UpcallBootstrap is the consumer-facing route (commonMain/.../ffi/upcall/UpcallBootstrap.kt); calling it
twice is safe (UpcallBootstrapTest). The published names live in __main__ and do not survive
Py_Finalize; the addresses behind them are process-lifetime, so a caller that re-initialises must publish
again (inferred from code; not covered by a test because the suite shares one interpreter).
python.multiplatform.ffi.upcall.UpcallTrampoline (commonMain) depends on nothing platform-specific.
Argument passing needs exactly one C shape:
PyObject *pm_invoke(long callableHandle, PyObject *args) // (long, long) -> long
Arity and types travel inside the tuple and the table entry, never in the C signature. A stub specialised
per signature would need one per (arity, tag-vector) — unbounded and impossible to pre-generate for a
closed world. The remaining slots were already in the downcall vocabulary:
| Slot | C signature | Carrier shape | |
|---|---|---|---|
PyCFunction |
PyObject *(PyObject *self, PyObject *args) |
(long, long) -> long |
the argument-passing shape |
getter |
PyObject *(PyObject *self, void *closure) |
(long, long) -> long |
same one |
initproc / setter / traverse |
int(long, long, long) |
(long, long, long) -> int |
pre-existing |
inquiry (tp_clear) |
int(PyObject *self) |
(long) -> int |
pre-existing |
destructor |
void(PyObject *self) |
(long) -> void |
pre-existing |
| name → handle | long(const char *) |
(long) -> long |
pre-existing |
| release / cancel | int(long) |
(long) -> int |
pre-existing (_pm_release, _pm_cancel) |
newfunc is unaccounted for only if tp_new is ever bound directly; it is not — construction goes
through a CONSTRUCTOR entry called from Python (§4.2).
The Array<Any?> an entry receives has one representation per TypeTag, which the generated cast
(TypeShape.castExpression) narrows from:
TypeTag |
Python | Kotlin |
|---|---|---|
INT |
int |
Long (a declared Int/Short/Byte is narrowed by generated code) |
FLOAT |
float |
Double |
BOOLEAN |
bool |
Boolean — a distinct Python type, not folded into INT |
STRING |
str |
String |
BYTES |
bytes |
ByteArray (NUL bytes survive; SPEC U-2) |
UNIT |
None |
Unit |
OBJECT |
int handle, or any Python object |
the Kotlin instance from HandleTable, or a PyObject |
| — | None |
null, whatever the tag |
OBJECT is resolved by what Python actually sent: Python cannot hold a Kotlin reference, so a Kotlin
object crosses as an ObjectReference integer ((generation shl 32) | slot, generation ≥ 1); anything
else is a Python object and reaches a parameter declared PyObject.
BYTES is correct and slow — one item at a time — because PyBytes_AsString is bound as a NUL-terminated
string read and PyBytes_AsStringAndSize is in no platform's EmbedAPI yet (UpcallTrampoline.toByteArray
KDoc). When it is, both directions collapse to one call.
- Arguments are borrowed.
PyTuple_GetItemlends, so aPyObjectbuilt over one takesborrowed = true.borrowed = falsegives back a reference nobody took — one per call — and the free lands somewhere unrelated. That bug crashed this repository twice. - The result is a new reference; Python takes ownership. Measured end to end through each platform's
real entry point: a hundred calls move the returned object's refcount by zero
(
commonTest/.../native/ffi/UpcallEntryTest.kt,theReferenceReturnedToPythonIsTakenOverExactlyOnce). - Nothing is thrown out of a trampoline. The return path is C; a Kotlin exception crossing a Panama
upcall stub terminates the VM, on Kotlin/Native the process. Every failure leaves as
NULLwith the error indicator set; a Kotlin exception becomes a Python exception andUnitbecomesNone(SPEC U-2,UpcallTrampolineTest).
withGIL skips PyGILState_Ensure when the thread's nesting depth is non-zero. That is right for Kotlin
code and wrong for an entry from C, because C may have released the GIL inside a scope that is still
open. ctypes.CFUNCTYPE does exactly that (unlike PYFUNCTYPE); the first version of the trampoline
segfaulted in _PyThreadState_GET (PyErr_Occurred+0x1c) on a thread whose own counter said it held the
GIL. Every trampoline entry (invoke, releaseObject, cancelCall) therefore takes its own
PyGILState_Ensure/Release pair unconditionally (UpcallTrampoline.attached), on every platform.
On Android PyGILState_Ensure is bound as ordinary JNI (PyGILState_EnsureN): a @FastNative or
@CriticalNative binding would let a thread blocked on the GIL stall the ART collector and deadlock the two
runtimes the moment upcalls exist (androidMain/README.md).
CallableHandle— table index + epoch (§1.2).-1isNONE; a valid handle is never negative.HandleTable/ObjectReference— a Kotlin object given to Python is rooted inHandleTableunder a generation-tagged handle; releases are generational, so a second release is a no-op and a stale handle never frees a reused slot (SPEC M-4,HandleTableTest,ProxyHandleLifetimeTest,OwnedResultLifetimeTest). Generated proxies give their handle back in__del__(§4.2).
Only the address-publishing step differs; the marshalling is shared.
| Platform | How Python reaches UpcallTrampoline |
Names published |
|---|---|---|
| Desktop | FFM Linker.upcallStub (UpcallStub.invokeWithArgsStubAddr, cancelCallStubAddr, …) wrapped by ctypes.CFUNCTYPE in UpcallBootstrap.desktop.kt |
_pm_resolve, _pm_invoke, _pm_release, _pm_cancel |
| iOS / androidNative | python.native.ffi.UpcallEntry (nativeMain): a PyMethodDef whose ml_meth is a staticCFunction over the trampoline, self carrying the handle; Kotlin/Native fills the struct, no C glue |
all five |
| Android (ART) | C shims in artMain/cinterop/jni_onload.def (pmp_upcall_invoke_meth, pmp_upcall_invoke_free_meth, pmp_upcall_resolve_meth, …, pmp_upcall_cancel_meth) calling python/native/ffi/UpcallCallbacks via CallStaticLongMethod |
all five |
| wasmJs | one @WasmExport("pmp_invoke") placed in CPython's __indirect_function_table with Table.set, five PyCFunctions over it told apart by an op code in self (§3.10) |
all five |
Tests: UpcallEntryTest (commonTest, runs on every target through the per-platform bindUpcallOrNull),
nativeTest/.../UpcallRawEntryPointTest.kt, androidInstrumentedTest/.../UpcallThreadAttachTest.kt.
The str/bytes contract of _pm_resolve: the generated support code sends bytes, because desktop's
ctypes.CFUNCTYPE(c_long, c_char_p) refuses a str; every PyMethodDef bootstrap accepts both
(PyUnicode_AsUTF8, on failure PyErr_Clear then PyBytes_AsString). The generated module's own
name → handle helper is called _pm_lookup so it never overwrites a bootstrap's _pm_bind
(handle → callable). PythonProxySourceTest guards that the generated support defines none of the five
bootstrap names. How these were found: when PythonProxyInstallTest was first run outside desktop, iOS
failed 14/14 because UpcallEntry.publish did not publish _pm_invoke, then 11/11 on the bytes
argument; ART later failed 11/11 on the same bytes gap in its separate C shim
(TypeError: bad argument type for built-in operation, on pmp_api26 and pmp_api36). The rule that
came out of it: the generator's assumptions about bootstraps are only proven by running
PythonProxyInstallTest on each target.
RegisterNatives binds a JVM external fun to a C function — the downcall direction. A
PyMethodDef's ml_meth must be a real C function pointer and Kotlin/JVM on ART can produce none. So the
entry points are C functions in jni_onload.def, and they call Kotlin with CallStaticLongMethod against
UpcallCallbacks, a class looked up once in JNI_OnLoad and held as a global ref. RegisterNatives appears
only for the one cold upcallPublish(long). This is the same inversion the cycle-collecting proxy type uses
for tp_traverse/tp_clear (ProxyCallbacks), down to reusing pmp_attach.
A Python worker thread is a bare pthread ART has never seen, so GetEnv fails and the shim must
AttachCurrentThreadAsDaemon (SPEC C-5).
UpcallThreadAttachTest.anUpcallArrivesOnAThreadCPythonCreatedRatherThanFailingToFindTheJvm upcalls from a
threading.Thread and asserts it did not land on the instrumentation thread.
Attach once per thread, detach at thread death. Attaching and detaching per call was the most expensive
thing on the Android upcall path (§7.6). The belief that forced it — "ART aborts if a thread exits without
detaching" — is false: Thread::ThreadExitCallback (runtime/thread.cc, same shape in android-8.0.0_r1
and main) warns on its first invocation and only reaches LOG(FATAL) on a second, which only an #else
branch Android does not compile could arm; bionic clears a key's value before its destructor and never
re-reads it. UpcallThreadAttachTest.aThreadThatExitsWithoutDetachingLeaksItsPeerRatherThanAbortingArt
lets an attached pthread exit undetached on pmp_api26 and pmp_api36 with no abort and no logcat warning.
The detach is still mandatory, for a different reason: an attachment never given back leaves its
java.lang.Thread in the thread list as a GC root. So the detach happens at thread death, in a
pthread_key_create destructor (pmp_thread_exit_detach) that defers to its second invocation so it cannot
run before ART's own exit callback.
Holding attachments is not a new risk. Peak simultaneous attachments do not change — under the per-call
scheme every worker upcalling at an instant is attached at that instant; the ceiling is the number of live
Python threads either way. What changes is that release must be reliable:
manyConcurrentWorkersAreEachAttachedOnceAndAllReleased holds 32 workers on a threading.Barrier, checks
each sees one ART thread and no two share one, and requires every peer reclaimable afterwards (green on both
emulators). The instrument reads GC reachability of the peer, not ThreadGroup.enumerate, which on API 26
does not report an attached native thread's peer at all; a positive control (peer unreclaimable while its
worker is parked mid-upcall) is kept in the test because it is what caught that.
The C shims take no GIL — CPython holds it when calling a PyCFunction — and the trampoline takes its own
pair (§3.5).
The original design said Python would reach @CName symbols via ctypes.CDLL(None) because both live in one
binary. On iOS neither half holds:
| Binary | pm_upcall_invoke / pm_upcall_resolve / pm_upcall_release_object |
|---|---|
androidNative libmultiplatform_python3.14.so |
exported (T in nm -D) |
iOS PythonMultiplatform.framework |
absent — a K/N framework exports the Objective-C surface plus the Konan runtime |
| Kotlin/Native test executable, either target | absent, also from per-file caches — never emitted |
and this project's iOS Python.framework binary has no _ctypes, so import ctypes raises
ModuleNotFoundError (Android's CPython ships _ctypes.so). The @CName functions
(pm_upcall_invoke, pm_upcall_resolve, pm_upcall_release_object, pm_upcall_cancel_call) are kept for a
C host and for ctypes on Android; UpcallEntry.invokeAddress publishes the same addresses without the
link (UpcallRawEntryPointTest.theRawEntryPointsAreCallableCFunctionsOfTheDocumentedShape). The real route is
the PyMethodDef of §3.7.
@WasmExport is valid only in the compilation that produces the .wasm, so the embedding application
declares @WasmExport("pmp_invoke") fun pmpInvoke(selfPtr: Int, argsPtr: Int): Int = UpcallEntry.invokeMethod(selfPtr, argsPtr); this repository reproduces that file in
wasmJsTest/.../UpcallExports.kt so the suite takes the application's path. wasmJsMain's UpcallEntry
puts the export in CPython's table (pmpRegisterUpcall) and builds PyCFunction_NewEx(def, self, NULL)
objects over it.
A PyCFunction carries self as well as the function pointer, so one export already backs any number of
distinct callables. Putting a value no handle can take in self turns it into a dispatcher — no second
export, and no op argument:
self |
Name | Shape |
|---|---|---|
-2 |
_pm_resolve |
(name: str | bytes) -> handle, -1 if none |
-3 |
_pm_invoke |
(handle, args_tuple) -> result |
-4 |
_pm_bind |
(handle) -> callable |
-5 |
_pm_release |
(handle) -> int |
-6 |
_pm_cancel |
(handle) -> int |
| other | bound callable | UpcallTrampoline.invoke(self, args) |
Negative ops are safe because CallableHandle.isValid is raw >= 0 and ObjectReference's generation is
≥ 1; -1 is not an op because it is CallableHandle.NONE and must still reach the trampoline's own refusal.
The application's file and verifyWasmAbiSignatures (317 @WasmImport + 3 glue) are unchanged by it.
Removing the UpcallEntry.publish call makes PythonProxyInstallTest fail 11/11 with the generated module's
guard ("the raw upcall entry points are not bound …"), so the passing run depends on the bootstrap.
The bare mechanism (call_indirect, no arguments) was measured at 3.1 ns in the former standalone
wasm-experiment (against 10.9 ns for addFunction around a JS closure); that experiment is now
wasmJsTest/.../WasmUpcallRouteOverheadTest.kt, measured from Python as in production. 3.1 ns must not be
quoted for an upcall that carries arguments — that is ~300 ns (§7.2).
PythonProxySource.render (commonMain/.../ffi/upcall/PythonProxySource.kt) renders Python from the
installed table; install() executes it. Not a KSP-emitted .py resource, because:
- There is no resource path that works everywhere. Kotlin/Native has no
getResourceAsStream.PythonPayloadnow puts a directory onsys.pathon desktop (jar resource) and Android (APK asset), but iOS, androidNative and wasm have no packaging step, so a shipped file would exist on two of five. - KSP does not know what will be installed. The table is assembled at run time and an application may install a subset; build-time Python would describe a table that may not exist.
ExposedCallablealready carries everything (kind,arity,isSuspend, names); a second generator could disagree with the first.
render is a pure function from entries to source text, pinned without an interpreter by
PythonProxySourceTest. Nothing here reintroduces reflection, so the native-image argument (§1.1) holds.
-
Module functions — published into
sys.modulesunder the Kotlin package (_pm_module), sofrom demo.calc import doubleLaterworks with no import hook (CPython checkssys.modulesbefore any finder). Each is wrapped bypython_multiplatform.kotlin_function(§4.3). -
Classes — a plain Python
classwhose__init__calls theCONSTRUCTORentry and stores the handle inself._pm_handle; methods and properties pass it asargs[0]. Aprivate setproperty refuses assignment. Interfaces and abstract classes have no constructor. -
Handle release —
__del__(tp_finalize, run bytp_dealloc) calls_pm_release, looked up once at class-definition time and carried as a default argument (globals areNoneduring finalisation). Without it,GeneratedProxyCostTest's constructor row ended at 78 002 live handles. Alternatives measured on CPython 3.13, construct + destruct net of a plain object:__del__+66 ns; per-instance holder +158 ns;weakref.refregistry +260 ns;weakref.finalize+555 ns. The accepted weakness: a subclass that defines__del__without callingsuper().__del__()leaks its handle (measured).ProxyHandleLifetimeTestpins release, including through a cycle. -
GC base — where
ProxyTypeFactory.installGcBase()succeeds it publishes the cycle-collectingPyType_FromSpectype as_pm_proxy_base, and generated classes subclass it through aPyMemberDefnamed_pm_handle, soself._pm_handle = …writes into the C slottp_traversereads (§6). Otherwise they fall back to_PmObject. Every platform'sactualnow implementsinstallGcBase(code read; theexpect's KDoc saying Android and wasm returnfalseis stale). -
Class statics (companion /
objectproperties, enum entries) — a descriptor on a metaclass rendered beside the class, so instances cannot see them (Kotlin's rule). Companion functions go on the metaclass too: astaticmethodin the class body would be reachable through an instance, and an accessor pair would disagree with the Kotlin declaration. -
Top-level
val/var— aproperty(a data descriptor) on aModuleTypesubclass generated per module (one shared type would answer the attribute on every module). The previous shape, a__getattr__/__setattr__pair on one shared subclass, cost ~50× more per read because__getattr__is the fallback hook:module_getattrofirst raises a fully formattedAttributeErrorthat the hook discards (§7.7). -
suspendfunctions —async defwrappers that await the result only if it is awaitable:async def _pm_f_0(a0): _pm_r = _pm_invoke(_pm_h_0, (a0,)) if hasattr(_pm_r, '__await__'): return await _pm_r return _pm_r
KotlinSurface (commonMain/.../ffi/upcall/KotlinSurface.kt) installs python_multiplatform, the binder's
root module, shared by both installers (PythonProxySource and the binding layer PythonxAdapter, which
lives at python_multiplatform.binding). Its KDoc is the contract (SPEC U-8):
- keyword arguments by Kotlin parameter names; an extension receiver is positional-only;
- a parameter with a Kotlin default has
default is python_multiplatform.KOTLIN_DEFAULT; leaving it out (or passing that object) means Kotlin's own default; - a required parameter after a defaulted one is keyword-only;
- annotations are the declared Kotlin type names; synthetic
$composer/$changed/$defaultnever appear; - an overload set answers
(*args, **kwargs); inspect.signature(fn)(built lazily via__wrapped__) andpython_multiplatform.describe(fn)give the metadata. The answer is the same whichever installer ran first (stamped epochs).
No member or parameter is renamed and the binder creates no pythonx module; a real pythonx package on
disk is what import pythonx loads. Tests: desktopTest/.../pythonx/KotlinNamedSurfaceTest.kt,
ksp-fixtures/compose/.../KotlinSignatureMetadataTest.kt. .pyi stubs are generated by the Gradle plugin
under Kotlin module paths only (SPEC B-7).
A generated proxy is an ordinary Python class, so Python can subclass it and override methods; instances of
such subclasses keep their handle and are collected through cycles (SPEC M-3; e.g.
desktopTest/.../ref/CycleCollectionTest.kt aPythonSubclassOfTheProxyTypeSurvivesBeingUntrackedTwice).
A Python override is not visible to Kotlin callers: nothing dispatches a Kotlin virtual call to a Python
method (no test asserts it, and the table has no such path). The original design's "Kotlin calls the
override through PyObject_CallMethod, no new stubs" is therefore not implemented.
PythonProxyInstallTest lives in commonTest. Each test target declares publishesProxyEntryPoints and
proxyBootstrapSupportsAsyncio as constants (commonTest/.../ffi/upcall/ProxyBootstrap.kt and its
actuals) instead of probing globals() at run time, so a target that stops publishing fails loudly instead
of silently taking the other branch.
| Target | publishesProxyEntryPoints |
proxyBootstrapSupportsAsyncio |
|---|---|---|
| desktop | true | true |
iOS / androidNative (nativeTest) |
true | true |
| Android ART | true | true |
| wasmJs | true | false (§5.8) |
Threaded delivery tests are split by how they create a thread: desktopTest/.../PythonProxyDeliveryTest.kt
(java.lang.Thread) and nativeTest/.../PythonProxyNativeDeliveryTest.kt (pthread_create).
SPEC U-5: await g.greetNow(1) works; a function that never suspends completes without a Future; cancelling
the Python future cancels the Kotlin coroutine. Implemented on desktop and Kotlin/Native; on ART the sample
demonstrates it (§5.8); planned on wasm.
CPython calls a C function pointer whose frame must return a PyObject * synchronously; a suspension has
nothing to return. runBlocking is not a solution: it blocks the thread the upcall arrived on — on Android a
CPython-created pthread — so the interpreter waiting for the answer is the thing being blocked. The only
option: return something else now and deliver the value later.
All three share the Kotlin half — start the coroutine inside the synchronous frame and park the result where it can be addressed later.
| Candidate | Verdict |
|---|---|
(A) handle + polling (is_done(h) / result(h)) |
Rejected. On wasmJs Python and Kotlin share one JS thread and resumption only happens when control returns to the host, so a polling loop that never yields prevents its own progress (inferred). Also burns a core and re-takes the GIL at every switch interval, and KSP cannot expose a generic Deferred<T> |
| (B) callback — Python passes a callable, Kotlin calls it on completion | Works: a Kotlin-created thread can take the GIL and call Python while the main thread is inside run_until_complete (measured, AsyncCompletionProbeTest.aKotlinCreatedThreadCanResolveAnAsyncioFutureTheInterpreterIsWaitingOn, which passes only after observing loop.is_running()). Hands all synchronisation to the user |
(C) asyncio — Kotlin creates a Future on the running loop and resolves it with loop.call_soon_threadsafe |
Chosen. Needs no new binding: PyImport_ImportModule, PyObject_GetAttrString and PyObject_Call* are enough (measured, AsyncCompletionProbeTest.everyApiThisNeedsIsAlreadyBoundSoAnAsyncioConventionAddsNoNewBinding). await kotlin_fn(x) is syntax Python users already know |
(C) is mechanically (B): call_soon_threadsafe is a downcall from the completion thread. Py_AddPendingCall
(in the Stable ABI, not bound in EmbedAPI) was not needed. The cost of (C): the application must be written
async — the loop must be running.
The fast path comes before the convention. suspend is a signature, not a promise to suspend: a body
that reaches no suspension point is complete before PendingCall.start returns
(PendingCallTest.aBlockThatNeverSuspendsIsAlreadyCompleteBeforeStartReturns), and its value is returned
directly with no Future and no event loop.
What would reverse this choice: if a target's loop could not be made to yield to its host, (B) would be the
better public surface there; if a completion thread on Android were found to need an ART attach, the
pmp_thread_exit_detach destructor (§3.8) would have to be replicated on the completion side. (A Kotlin/JVM
thread is already known to ART, so no attach is expected — inferred; the ART sample in §5.8 resumes from a
java.lang.Thread and works.)
BindingPolicy.isSuspending selects a body shape:
{ args -> fixture.library.blockingTopLevel(args[0] as Long) } // ordinary
{ args -> python.multiplatform.ffi.upcall.PendingCall.start { fixture.library.suspendingTopLevel(args[0] as Long) } } // suspend
The braces are a suspend lambda; it compiles with the stdlib only. kind is unchanged (a suspending
member is still METHOD, receiver in args[0]) and returnType is the declared return type, so the
fast path marshals exactly like a synchronous entry. ExposedCallable.isSuspend is a flag rather than
SUSPEND_* variants of CallableKind because the axes are orthogonal: kind says where the arguments are,
isSuspend what comes back. Pinned by ksp-fixtures/app/.../GeneratedSuspendTest.kt against
ksp-fixtures/library/.../Suspending.kt, which pairs every suspending declaration (top-level, member,
companion, interface, object) with a non-suspending control in the same scope.
A suspend function type (val h: suspend (Long) -> Long, a parameter or return of that type, nullable,
behind a typealias, or nested in a generic argument) is not exposed and KSP warns
(FragmentScanner.warnUnexposableType); GeneratedSuspendTest asserts each rejection. (An earlier state
exposed it as an opaque OBJECT handle no entry could call. The BindingPolicy.isSuspending KDoc still says
that; it is stale.)
commonMain/.../ffi/upcall/PendingCall.kt, pinned by PendingCallTest:
- No
kotlinx.coroutinesdependency.startCoroutineandContinuationare stdlib; the dispatcher a user'ssuspend funneeds is already on the user's classpath. - The context is the
PendingCallitself (aCoroutineContext.Element), with no dispatcher, so the body runs on the calling thread until its first real suspension — which is where the fast path comes from, and why work before the first suspension runs inside the C frame, holding the GIL. - Nothing escapes: body and listener failures are parked, as in the trampoline.
- No new handle machinery: it is an ordinary object in
HandleTable. - Unsynchronised, on the boundary's rule that every mutation happens on a GIL-holding thread; a completion that reaches Python takes the GIL anyway.
AsyncUpcall.deliver runs on UpcallTrampoline.invoke's entry.isSuspend branch:
| Case | Returned | Cost |
|---|---|---|
| already complete, success | the real value (marshalResult) |
not even import asyncio |
| already complete, failure | NULL + error indicator |
identical to a synchronous failure |
| really suspended | asyncio.Future (new reference) |
get_running_loop + create_future |
On completion: withGIL { loop.call_soon_threadsafe(_pm_settle, future, ok, payload) }. withGIL, not the
trampoline's unconditional pair, because this is not a C entry: a fresh completion thread has depth 0 and
really ensures; a completion inside a later upcall correctly nests. The Kotlin wrapper and Python each hold a
reference to the Future; the completion lambda keeps the wrapper alive until it fires.
The fast path is wider than "did not suspend". If the completion thread resumes the continuation while the
upcall frame is still running — CFUNCTYPE releases the GIL for the upcall itself — deliver sees isDone
and returns the value. Correct, but the synchronous-completion ratio is a function of the race, and a test of
the slow path must observe that a Future actually crossed (AsyncUpcallDeliveryTest records
type(r).__name__; the proxy tests count create_future calls instead and assert 0 on the fast path —
PythonProxyInstallTest.theSameGeneratedProxyBuildsNoFutureWhenTheKotlinBodyNeverSuspends).
No running loop: a call that really suspends fails with RuntimeError: no running event loop, and the
coroutine has already started (whether it suspends is only known by starting it); its resumption is dropped
(AsyncUpcallDeliveryTest.aSuspendingEntryThatSuspendsWithNoRunningLoopFailsInsteadOfReturningSomethingUnusable).
A suspending entry that never suspends works with no loop at all
(aSuspendingEntryThatNeverSuspendsHandsBackTheRealValueAndBuildsNoFuture).
A completion landing on a cancelled Future is dropped, not raised. Measured before the fix
(AsyncUpcallCancellationTest): await got CancelledError; the loop's call_exception_handler got one
InvalidStateError: invalid state; the completion thread's error indicator was clean and the next unrelated
upcall worked — no contamination, just an unactionable log entry. Two guards, only the second a guarantee:
AsyncUpcall.resolve checks done() before scheduling (cheap, racy), and the scheduled callback is
_pm_settle, which checks done() again on the loop thread. Removing only _pm_settle's guard turns
exactly the race test red; the race is forced, not left to timing (the Python coroutine spins without
awaiting while Kotlin schedules, then cancels).
Kotlin-side cancellation is cooperative, by construction. The continuation startCoroutine receives is
the coroutine's completion; the suspension point's continuation belongs to whoever suspended (the user's
suspendCoroutine, their dispatcher, kotlinx.coroutines). PendingCall never sees it, the stdlib cannot
reach it, and resuming twice is undefined. Forced cancellation is what a Job tree does. What works:
ensureActive() (top-level, reads coroutineContext[PendingCall]) throws CancellationException once the
call is cancelled — the same contract as kotlinx.coroutines, where a body that never checks is never
cancelled either. The element is not a ContinuationInterceptor, so the fast path is untouched.
suspend fun slowSum(n: Long): Long {
var total = 0L
for (i in 0 until n) { ensureActive(); total += step(i) }
return total
}
Early notification. Python's fut.cancel() reaches Kotlin before completion:
fut.cancel() -> done callback (call_soon) -> _pm_cancel(handle) (long) -> int
-> UpcallTrampoline.cancelCall (unconditional PyGILState_Ensure)
-> HandleTable.resolveRaw -> PendingCall.cancel() -> next ensureActive() throws
_pm_cancel has _pm_release's shape (no new stub shape). The PendingCall handle is captured as a default
argument of the done callback rather than set as a Future attribute. AsyncUpcall.armCancellationNotice
registers the handle only if _pm_cancel and _pm_release exist in __main__; a host without them keeps
completion-time observation and leaks nothing (a NameError inside a loop callback would be another
unactionable log). AsyncUpcallEarlyCancellationTest was red first, with the old behaviour as its message
("Kotlin did not learn about the cancellation until the call completed"); it asserts
isCancelled && !isDone on a call never resumed since cancellation (the old code also set the flag, but only
at completion), then resumes once and requires CancellationException and isDone.
Two releasers by design. _pm_watch's done callback calls _pm_cancel if cancelled and always
_pm_release; AsyncUpcall.resolve releases too, in a finally. They cover different holes:
| Releaser | Only it covers |
|---|---|
| Python done callback | a cancelled body that never cooperates — the Kotlin completion never runs |
Kotlin resolve |
the loop stopping before the scheduled done callback ran |
Generational release makes the second a no-op and a late _pm_cancel(stale) harmless. Measured with
HandleTable.liveCount (AsyncUpcallEarlyCancellationTest):
| Case | Live handles |
|---|---|
| while a really-suspended call is pending | baseline + 1 |
| after cancel, coroutine not yet finished | baseline (Python released it) |
| after cancel and body exit | baseline |
| after normal completion | baseline |
| fast path | baseline — nothing registered (theFastPathRegistersNoHandleAtAll) |
Registration happens after deliver's isDone early return, so the fast path touches neither HandleTable,
__main__ nor add_done_callback. The slow path pays two __main__ lookups and one _pm_watch call. Not
reclaimed: a call whose Future never settles and whose coroutine never ends — that has leaked its
continuation already.
The desktop tests use java.lang.Thread and java.util.concurrent, so they cannot move to commonTest; the
claims are re-verified on Kotlin/Native with nativeTest/.../NativeThread.kt, a thin pthread_create
wrapper. Not Worker: the question is whether a completion may come from a thread the Kotlin/Native runtime
never attached itself, which Worker would sidestep — the same situation
CycleCollectionTest.testDeallocOnAThreadCPythonCreated measures for tp_dealloc.
nativeTest |
Desktop counterpart | Re-verifies |
|---|---|---|
AsyncUpcallNativeDeliveryTest (2 tests) |
AsyncUpcallDeliveryTest |
delivery from a foreign thread while the loop runs; fast path takes no asyncio |
AsyncUpcallNativeEarlyCancellationTest |
AsyncUpcallEarlyCancellationTest |
_pm_cancel reaches ensureActive() before completion |
AsyncUpcallNativeCancellationTest |
AsyncUpcallCancellationTest |
a completion on a cancelled Future is dropped; no error indicator leaks |
All passed on the iOS simulator, three repeated runs. They are in nativeTest, which
androidNativeArm64Test also runs, and they predate the androidNative full-suite runs recorded in §5.8
(316 tests, 0 failures) — but no per-test androidNative result for them was recorded (unverified).
AsyncUpcallPortabilityTest (commonTest) keeps only the two paths that return before touching asyncio — fast
path and failure-before-suspend — which need no thread and run on all targets.
Desktop — everything above (AsyncUpcallDeliveryTest, AsyncUpcallCancellationTest,
AsyncUpcallEarlyCancellationTest, PythonProxyDeliveryTest).
iOS simulator — asyncio is available. The iOS Python.framework binary has no lib-dynload, but asyncio
comes from the separately extracted BeeWare stdlib (build/python-stdlib/ios-simulator/lib/python3.14/ with
asyncio/ and lib-dynload/_asyncio…so, select…so, _socket…so).
iosSimulatorArm64Test/.../AsyncioAvailabilityProbeTest.kt (isolated in its own file) runs
asyncio.new_event_loop().run_until_complete(...) and passes. Delivery, early cancellation and the silent drop
pass (§5.7).
Android (ART) — proxies, including async ones, run on pmp_api26 and pmp_api36
(connectedDebugAndroidTest: 336 tests, 0 failures on each; PythonProxyInstallTest's 11 on the real path).
The sample app on both emulators showed:
| Section | Output |
|---|---|
| 3 — upcall | handle 4294967327 -> 0 · with args -> presses x3 = 0 (called from Python via _pm_resolve/_pm_invoke) |
| 5 — class proxy | installed over PyMethodDef via JNI: 466 lines, 2 proxy classes, Greeter('Kotlin').greet(2) -> hello Kotlin! hello Kotlin!, g.greetings = 99 -> AttributeError (private set held) |
| 6 — companion | Greeter.forget() -> 100, then built=0, Greeter('x').built -> AttributeError (companion is class-only) |
| 7 — fast path | await g.greetNow(1) -> hello fast path!, Futures created -> 0 |
| 7 — really suspends | await g.greetLater(2) -> hello slow path! hello slow path!, Futures created -> 1, raised -> None, completer thread -> clean |
The last row resumes the parked continuation from a java.lang.Thread, as on desktop. (The original record
adds that pmp_attach runs on that path too; a java.lang.Thread is already known to ART, so that is
(unverified).) The iOS sample cannot yet show that row (a Kotlin/Native completion thread needs a
Worker or memory-model decision in the sample; the library's PythonProxyNativeDeliveryTest does it with
pthread_create).
wasmJs — the synchronous proxy surface runs (10 of PythonProxyInstallTest's 11); await does not yet:
- The trap is fixed.
import selectors/import asyncioused to kill the Node process withRuntimeError: unreachable. That was the third symptom:select.poll().poll(0)raisedSuspendError: trying to suspend without WebAssembly.promising, which unwound CPython's C frames withoutPy_END_ALLOW_THREADS, so the outerPyGILState_ReleasehitFatal Python error: PyGILState_Release: thread state … must be current, thenabort(). Cause: Emscripten decides JSPI at run time (if (WebAssembly.Suspending) …) and wraps onlymaininWebAssembly.promising; this library never callsmain(it issuesPy_Initializeas an ordinary wasm call). The samepython.wasmreturns[]on Node 22 (no JSPI) and dies on Node 26 (JSPI on by default, no flag to disable it); Gradle's runner uses Node 26.selectorswas the first victim because it callspoll(0)at import time;asynciodied only through it. Fix:cpython.mjs(python-multiplatform/src/wasmJsMain/resources/) deletesWebAssembly.promisingandWebAssembly.Suspendingbefore calling the Emscripten factory, putting Node 26 on the synchronous path — which is what this library needs, since every call arrives from Kotlin as a synchronous wasm call. Asys.modules['selectors']shim was rejected: it only moves the death to the first loop iteration, hands users a fake module, and fixes the wrong layer.wasmJsTest/.../WasmSelectorsImportTest.kt(4 tests):poll/selectreturn;DefaultSelector is PollSelector;asyncio,asyncio.events,asyncio.base_eventsimport; a coroutine runs to completion on a selector-free loop. - The default event loop still cannot be built.
BaseSelectorEventLoop.__init__→_make_self_pipe→socket.socketpair()→ Emscripten___syscall_listen→require('ws')→MODULE_NOT_FOUND. Installingws(tried temporarily withnpm("ws", "8.18.0")and a probe, then reverted) only exposes the next wall:OSError: [Errno 28]fromlsock.accept(), because Emscripten's socket emulation needs a real WebSocket handshake to populatependingand_fallback_socketpair()runsbind/listen/connect/acceptinside one synchronous wasm call, so Node's event loop never ticks in between. No npm package fixes that, and re-enabling JSPI brings back §1'sSuspendError. In a browserlisten()does not exist at all (ENVIRONMENT_IS_NODEonly). - Decision:
proxyBootstrapSupportsAsynciostays false on wasmJs. A selector-free loop works as a mechanism (theWasmSelectorsImportTestrun), but shipping one means keeping the wholeRunner/BaseEventLoopcontract (call_later,shutdown_asyncgens,shutdown_default_executor, exception handling,KeyboardInterrupt) correct across CPython versions. Whether the library ships a custom loop is an open decision (§8). Until then a wasm application can rely on the fast path and on failure-before-suspend (AsyncUpcallPortabilityTest).
A Python proxy holding a Kotlin object that holds a Python object forms a cycle neither collector can break
alone: CPython's traversal stops at the opaque handle, and the JVM sees the handle table as a live root.
Reference counting never frees cycles in any language. The fix makes the Kotlin side's Python references
visible to tp_traverse: the proxy's traverse reaches through the handle and enumerates the PyObject-typed
properties of the Kotlin object. KSP already walks every exposed class, so a traverse body per class is one
more generated field (FragmentScanner.traverseBody, ClassModel.traverseBody), not a new mechanism. A
reflection-based binding cannot afford this, which is why mature ones document "do not create cycles"
instead. Generated proxies reach the traversable type through the GC base of §4.2.
Status: SPEC M-3 — implemented on desktop (desktopTest/.../ref/CycleCollectionTest.kt,
ksp-fixtures/app/.../RefHolderCycleCollectionTest.kt), partial elsewhere. Mechanism and the hard parts
(traverse during collection, tp_clear mutating Kotlin state, cycles closing on the Kotlin side) are in
object-lifetime.md.
How to reproduce: the cost table is cut by ./benchmarks/cost-table.sh --runs 3 (see
cost-table.md "다시 뽑는 법"). Individual tests print their own blocks:
| Test | Source set | Prints |
|---|---|---|
UpcallBoundaryCostTest |
commonTest (python/native/ffi/) |
one upcall vs. downcall of the same shape vs. trampoline, with controls |
GeneratedProxyCostTest |
commonTest (ffi/upcall/) |
proxy layer over the raw call; install cost |
UpcallOverheadTest |
androidInstrumentedTest |
worker vs. instrumentation thread, first upcall on a fresh worker |
UpcallOverheadTest |
commonTest (reflection/) |
name resolution vs. cached handle, inside Kotlin |
WasmUpcallRouteOverheadTest |
wasmJsTest |
Table.set route vs. addFunction around a JS closure |
Rules: run alone, unfiltered, after checking uptime (AGENTS.md §8); ranges are min–max over the stated runs;
a single reading is not a measurement.
Current figures, from cost-table.md (commit 958c0082b294, 2026-08-14, Apple M1, UpcallBoundaryCostTest
warmup 100 000, 10 000 iterations per loop, 3 runs per target; desktop/iOS/wasmJs/androidNative from
cut-hostless, ART from cut-api36 / cut-api26):
| Platform | upcall | downcall, same shape | trampoline alone | upcall / downcall | upcall / trampoline |
|---|---|---|---|---|---|
| desktop (JVM 21.0.12, macOS arm64) | 542.84–576.45 ns | 260.27–265.81 ns | 170.44–247.42 ns | 2.04–2.20x | 5.54–6.46x |
| wasmJs (Node) | 302.41–310.72 ns | 96.53–100.95 ns | 149.50–164.88 ns | 3.07–3.14x | 2.39–2.46x |
| iOS simulator (26.2, arm64) | 2253.20–2312.97 ns | 1585.46–1655.40 ns | 2794.42–2843.16 ns | 1.39–1.42x | 1.14–1.15x |
androidNative pmp_api36 |
3348.75–3443.23 ns | 2194.26–2365.61 ns | 3957.01–4069.40 ns | 1.45–1.54x | 1.19–1.20x |
androidNative pmp_api26 † |
3234–3310 ns | 2470–2554 ns | 2731–3228 ns | 1.28–1.32x | 1.02–1.20x |
ART pmp_api36 ‡ |
995.61–1154.10 ns | 804.27–838.60 ns | 1100.87–1167.17 ns | 1.18–1.43x | 1.10–1.29x |
ART pmp_api26 ‡ |
1196.52–1272.28 ns | 1253.41–1295.79 ns | 1800.86–1863.97 ns | 0.92–1.01x | 1.05–1.18x |
† Not in the cost table (it cuts androidNative on pmp_api36 only); hand-cut at warmup 100 000, five runs.
Its spread is the emulator's: in two rounds a different single run was the outlier in a different row (5554 ns
downcall at warmup 3 000; a 3227.87 ns GIL-held trampoline at 100 000, which alone widens upcall / trampoline
from 1.16–1.18x). Read pmp_api36 for the shape and pmp_api26 for how much confidence an emulator supports.
No androidNative figure is a hardware number.
‡ The cost table marks both ART cuts "failed"; the failures were in PhantomCleanerRegistryTest, not the
benchmark, and were fixed in c180c46f.
Controls from the same cut:
| Target | empty Python loop | pure-Python callee | GIL-held trampoline |
|---|---|---|---|
| desktop | 8.67–8.68 ns | 25.77–26.09 ns | 85.52–97.95 ns |
| iOS simulator | 9.84–10.17 ns | 36.80–38.32 ns | 1948.58–2020.43 ns |
| wasmJs | 14.74–15.34 ns | 47.89–49.22 ns | 123.15–129.83 ns |
androidNative pmp_api36 |
17.22–17.31 ns | 48.85–58.45 ns | 2790.19–2857.59 ns |
ART pmp_api26 |
20.01–20.08 ns | 56.10–65.66 ns | 1073.40–1142.08 ns |
ART pmp_api36 |
17.22–18.14 ns | 58.40–65.30 ns | 873.48–911.95 ns |
The previous hand-cut round (warmup 100 000; five full suites per device, eleven on the hosts) read:
desktop 510–560 ns, wasmJs 290–304 ns, iOS 2266–2301 ns, androidNative pmp_api36 3228–3275 ns,
pmp_api26 3234–3310 ns, ART pmp_api36 944–1063 ns, ART pmp_api26 1146–1233 ns. (The predecessor document
labelled this list "warmup 3,000"; that was wrong — the 3 000-warmup desktop and wasmJs figures were 674 ns and
322 ns.) In that round each host had exactly one outlying run in eleven, kept rather than dropped: desktop's
GIL-held trampoline once read 186.81 ns against 76–91 ns (its ratio 2.89x against 5.62–7.02x); one wasmJs run
inflated every boundary row together (upcall 432.55, downcall 144.64, trampoline 264.44 ns) while the empty GIL
scope stayed at 24.67 ns, so its ratio (2.99x) stayed in band — the case for preferring the ratio on wasm.
UpcallBoundaryCostTest warmed 3 000 calls and every figure it published was taken before the host JIT
finished tiering up, so the number depended on how much upcall traffic the rest of the suite had pushed
through the process first. Diagnosis: on wasmJs, commit 4472f83a made generated proxies installable, so
GeneratedProxyCostTest began driving ~270 000 upcalls before this test, and short-circuiting that one test
restored the figure; the original absolutes (desktop 861–1313 ns, wasmJs 703–1075 ns) fell on re-measurement
with the downcall column standing still. The fix (7e9c6b8c) is the warmup count, chosen from a measured
convergence point; bisections, the controlled experiment and the 40-rep sweep that identified host JIT tier-up
and ruled out CPython-side state are in downcall-design.md ("Ratio consistency" and
"The benchmark was measuring the benchmark").
Check that it is fixed: at warmup 100 000, short-circuiting GeneratedProxyCostTest moves desktop
510–560 → 501–530 ns and wasmJs 290–304 → 287–301 ns (overlapping); at 3 000 the same lever moved desktop
674 → 537 ns and wasmJs 322 → 292 ns. 13 desktop and 12 wasmJs full-suite runs, 3 of each with the lever
pulled.
JIT hosts fell, AOT hosts did not — the prediction that separates the explanations:
| re-measured at 100 000 | upcall, old → new | downcall, old → new |
|---|---|---|
| iOS simulator | 2263–2502 → 2266–2301 ns | 1599–1826 → 1599–1610 ns |
androidNative pmp_api36 |
3339–3598 → 3228–3275 ns | 2170–2222 → 2155–2229 ns |
androidNative pmp_api26 |
3314–4140 → 3234–3310 ns | 2636–5554 → 2470–2554 ns |
against desktop 674 → 537 ns, wasmJs 322 → 292 ns and ART pmp_api36 2301–3086 → 944–1063 ns (API 26 barely
moved, 1209–1329 → 1146–1233 ns). ART has a JIT and moved like desktop and wasmJs. "Did not fall" is not "needed
no warmup": from cold, androidNative's first 10 000 upcalls read ~1.3× the plateau; the old Kotlin/Native rows
landed near it only because the rest of the suite had warmed the path.
Device sweep — 40 consecutive reps of 10 000 calls from cold; first-rep ratio = rep 1 / mean of reps 21–40:
| Target | rep 1 / plateau (Python-driven upcall) | flat from | margin at 100 000 |
|---|---|---|---|
| iOS simulator | 1.01x | ~10 000 | 10x |
androidNative pmp_api36 |
1.27x | ~40 000 | 2.5x |
androidNative pmp_api26 |
1.30x | ~30 000 | 3.3x |
ART pmp_api26 |
1.93x | ~20 000, noisily | 5x |
ART pmp_api36 |
8.56x | ~90 000–100 000 | ~1x — none |
ART pmp_api36's sweep: 9194.8, 3218.9, 2288.7, 1648.3, 1218.3, 1173.3, 1129.5, 1532.7, 1057.8, 1044.6 ns against
a plateau of 1074.2 — still 9% high at 70 000. Its downcall is flat from ~80 000 and its GIL-held trampoline
from ~50 000, so the Python-driven upcall sets the requirement. The controls are flat from the first rep on all
five device configurations (iOS 10.5 → 10.2 ns and 37.9 → 38.0 ns; ART pmp_api36 17.3 → 17.3 ns and
49.7 → 50.4 ns), so the sweep measures the boundary, not the interpreter. The host sweeps behind WARMUP's doc:
40 000 cold / 70 000 warm on desktop, 70 000 on wasm; overhead/BenchmarkTest's worst desktop row
(PyUnicode_AsUTF8 (8 chars)) reads 557 ns at 40 000 and 78–84 ns from 70 000. 100 000 is therefore the
constant everywhere: UpcallOverheadTest (ART only) now uses it for its Python-driven loops and Kotlin-driven
downcall rows (previously 3 000 and 2 500), and overhead/BenchmarkTest uses it uniformly. With no margin on
ART pmp_api36, a slower device or busier host may read the boundary before it settles. The
GeneratedProxyCostTest warmup of 5 000 per row is deliberate: nineteen rows warm the shared _pm_invoke path
19 × 5 000 before the first timing.
Filtered runs. A desktop --tests run of this class alone reads 506–550 ns, inside the full-suite band, and
its controls match (8.4–8.6 vs 8.4 ns). wasmJs is the exception: filtered it reads 321–353 ns and its
pure-Python controls read 23.1 and 83.1 ns against the suite's 15.0 and 47.2 — CPython is itself a wasm
module there, so a short-lived Node process runs the interpreter 55–74% slower. Tripling the warmup to 300 000
does not move it (host-lifetime-bound). wasmJs's ratios survive filtering (2.66–3.13x vs 2.91–3.14x); its
absolutes are a full-suite figure only. The devices do not have the effect (three filtered runs vs five
full-suite runs):
| filtered vs full suite | upcall, filtered | upcall, full | empty Python loop | pure-Python callee |
|---|---|---|---|---|
| iOS simulator | 2320–2322 ns | 2266–2301 ns | 9.9–11.4 vs 9.9–10.3 ns | 36.8–38.0 vs 36.8–38.3 ns |
androidNative pmp_api36 |
3275–3294 ns | 3228–3275 ns | 17.21 vs 17.22 ns | 48.9–58.2 vs 48.9–74.2 ns |
androidNative pmp_api26 |
3245–3280 ns | 3234–3310 ns | 20.0–23.6 vs 20.0–20.6 ns | 51.7–55.3 vs 51.7–62.0 ns |
ART pmp_api36 |
996–1031 ns | 944–1063 ns | 17.2–19.3 vs 17.2–22.8 ns | 49.0–60.0 vs 49.2–63.3 ns |
ART pmp_api26 |
1118–1171 ns | 1146–1233 ns | 20.0–20.1 vs 20.0–21.5 ns | 51.7–63.4 vs 51.7–65.6 ns |
So device absolutes are quotable as absolutes: there CPython is native code the host executes, not a wasm module the host compiles.
ART rows are measured on the instrumentation thread, which is fair now that the attach is amortised (§7.6).
On API 26 the worker is 43–249 ns dearer than the instrumentation thread, so the ART row is that much optimistic
against a threading.Thread caller.
androidNative had no test run task until :python-multiplatform:androidNativeArm64Test
(python-multiplatform/build.gradle.kts): KGP only registers execution tasks for the host and simctl. The task
pushes test.kexe and the CPython prefix (Py_Initialize aborts without a stdlib) to /data/local/tmp, runs
it under the TeamCity logger and converts the service messages to JUnit XML under
build/test-results/androidNativeArm64Test/, on every connected device with a matching ABI. First runs: 252
tests, 0 failures, both emulators, five runs — artMain and the androidNative cinterop came up clean.
UpcallTrampoline takes its GIL pair unconditionally (§3.5), so driving it from a bare Kotlin thread pays a real
GIL acquisition per call while Python drives it already holding the GIL. Uncontrolled, the boundary prices
negative on devices: at warmup 100 000, five runs each, upcall / trampoline was 0.79–0.82x on the iOS
simulator, 0.82–0.85x and 0.77–0.78x on androidNative pmp_api36/pmp_api26, 0.86–0.95x and 0.65–0.70x on ART
pmp_api36/pmp_api26. It is measured twice, differing in that one thing, with an empty
Python3.withPython { } for scale (hand-cut round, warmup 100 000):
| desktop | wasmJs | iOS sim | androidNative 36 | androidNative 26 | ART 36 | ART 26 | |
|---|---|---|---|---|---|---|---|
| trampoline, caller holds nothing | 164–181 ns | 146–164 ns | 2794–2862 ns | 3789–3893 ns | 4174–4267 ns | 1093–1148 ns | 1693–1822 ns |
| trampoline, GIL already held | 76–91 ns | 122–129 ns | 1962–1990 ns | 2699–2760 ns | 2731–3228 ns | 793–907 ns | 1012–1087 ns |
Python3.withPython { }, empty |
50–57 ns | 24–28 ns | 720–731 ns | 932–1002 ns | 1112–1221 ns | 276–303 ns | 498–549 ns |
The GIL-held row is the comparable denominator (§7.1 uses it). Desktop and wasmJs never showed the effect because their GIL scope is 24–57 ns against 276–1221 ns on a device. At the old 3 000 warmup desktop's empty scope read 124–152 ns and wasmJs's 79–81 ns — the cheapest operation is the one an unwarmed loop overstates most. wasmJs's GIL-held trampoline is dearer than desktop's although its GIL scope is half the price, so wasm pays for the trampoline body, not the GIL.
- iOS and androidNative: the scaffolding, not the boundary. There is no runtime boundary on these targets —
a
staticCFunctionin the same binary. What is expensive is per-call scaffolding every C API call shares: an emptywithPythonscope is 720–1002 ns there against 50–57 ns on desktop and 24–28 ns on wasm, and the same inflation hits the downcall denominator, which is why the ratio sits near 1.4. (In the round that measuredPy_IncRef + Py_DecRef— two calls, two GIL scopes — it cost 1551–2032 ns on iOS and 2010–2132 ns on androidNative against 110–120 ns on desktop and 44–49 ns on wasm; those device figures were unwarmed, so the multiple is not quotable, but the gap is wide enough that the conclusion survives.) Work to move either number belongs in the scaffolding, not inUpcallEntry. - Desktop: a real boundary. GIL-held trampoline 76–91 ns against a 510–560 ns upcall (hand-cut round): ~420–480
ns is the Panama upcall stub plus the
ctypesshim, the largest relative boundary measured (5.62–7.02x in that round, 5.54–6.46x in the cost table). Desktop's_pm_invokeis actypes.CFUNCTYPE(and in this benchmark a Pythonlambda *a: _pm_invoke(_pm_h, a)), where other targets bind aPyCFunction, so desktop's row is an upper bound on its boundary — it includes ctypes' argument conversion and, in the benchmark, one extra Python call (26.7–27.2 ns). - wasmJs: ~300 ns with arguments, of which 122–129 ns is the trampoline; the rest is the Python-side callable and the crossing with a real tuple. Its scaffolding is the cheapest and most stable (24.47–27.52 ns for an empty scope across eleven runs), as there is no OS thread machinery; that row reads 34–37 ns when the class is run alone, for the reason in §7.2.
- ART: one JNI upcall per call, plus the per-thread attach the first time a CPython-created thread calls (§7.6). Neither is on the marshalling path.
GeneratedProxyCostTest (commonTest) prices each proxy surface against the raw _pm_invoke call it wraps,
measured through the same object, in the same run, each row a zero-argument Python function called by one
shared loop (the floor is subtracted). Each row is the minimum of three timed loops: the quantity is a
difference of tens of ns on a boundary of hundreds and noise is one-sided — with one loop per row, four runs put
the instance-method delta at −17, −31, −60 and −71 ns. The last row of each table is the report's own
resolution: two raw rows are the same call (_pm_invoke(h, ()), different handle), so their difference is what
cannot be resolved. Three runs; taken in the round before §7.2's warmup fix, so read internally (raw against
proxy), not against §7.1's absolutes.
| Surface a user writes | raw boundary | through the proxy | ratio | proxy layer adds |
|---|---|---|---|---|
| desktop (JVM 21.0.12, macOS arm64) | ||||
demo.calc.ping() module function |
452–475 ns | 468–485 ns | 1.00–1.03x | +5…+17 ns |
c.increment(5) instance method |
514–558 ns | 537–573 ns | 1.02–1.05x | +14…+27 ns |
c.value property read |
495–527 ns | 509–542 ns | 1.02–1.03x | +14…+19 ns |
c.label = 'x' property write |
522–548 ns | 569–609 ns | 1.09–1.11x | +47…+62 ns |
Counter.created static read (metaclass) |
449–485 ns | 480–510 ns | 1.01–1.07x | +6…+34 ns |
Counter.created = 12 static write |
466–507 ns | 511–573 ns | 1.07–1.13x | +34…+66 ns |
demo.calc.tally top-level read |
449–485 ns | 484–521 ns | 1.03–1.07x | +14…+36 ns |
demo.calc.tally = 9 top-level write |
466–507 ns | 520–553 ns | 1.08–1.11x | +44…+54 ns |
Counter(10) constructor |
682–761 ns | 588–627 ns | — | not separable (below) |
| resolution | −3…+9 ns | |||
| iOS simulator (arm64) | ||||
demo.calc.ping() module function |
1801–1890 ns | 1829–1895 ns | 1.00–1.01x | +6…+28 ns |
c.increment(5) instance method |
2901–2987 ns | 2895–3029 ns | 0.98–1.01x | −44…+42 ns |
c.value property read |
2504–2601 ns | 2510–2620 ns | 0.99–1.00x | −3…+19 ns |
c.label = 'x' property write |
2832–2940 ns | 2925–3092 ns | 1.03–1.05x | +92…+153 ns |
Counter.created static read (metaclass) |
1838–1931 ns | 1906–1982 ns | 1.01–1.03x | +28…+68 ns |
Counter.created = 12 static write |
2180–2315 ns | 2277–2365 ns | 1.02–1.04x | +50…+103 ns |
demo.calc.tally top-level read |
1838–1931 ns | 1927–1981 ns | 1.02–1.05x | +48…+99 ns |
demo.calc.tally = 9 top-level write |
2180–2315 ns | 2286–2363 ns | 1.02–1.04x | +48…+105 ns |
Counter(10) constructor |
2591–2724 ns | 2769–2864 ns | 1.05–1.06x | +140…+177 ns |
| resolution | +15…+79 ns |
The boundary is the whole cost: the generated layer adds tens of ns (1.00–1.13x); bypassing proxies to call
_pm_invoke by hand would recover almost nothing. The desktop constructor row is not reportable: when it was
taken nothing released constructed objects, so both rows ran against a HandleTable growing to 78 002 live
handles and the raw baseline moved between 511 and 775 ns (since fixed by __del__, §4.2). On the simulator it
is a stable +140…+177 ns (type.__call__ + __init__ frame + attribute store). Half the simulator rows are
inside its resolution; desktop resolves to ±10 ns.
wasmJs (wasmJsNodeTest, one run as printed; harness floor 62.48 ns):
module function raw 301.91 ns proxy 318.98 ns 1.05x (+17.06 ns)
constructor raw 719.75 ns proxy 1070.98 ns 1.48x (+351.23 ns)
instance method raw 339.22 ns proxy 357.47 ns 1.05x (+18.24 ns)
property read raw 290.94 ns proxy 308.41 ns 1.06x (+17.47 ns)
property write raw 360.42 ns proxy 438.77 ns 1.21x (+78.34 ns)
static read raw 228.82 ns proxy 300.94 ns 1.31x (+72.12 ns)
module read raw 228.82 ns proxy 324.39 ns 1.41x (+95.57 ns)
Resolution: two identical raw rows differ by -73.09 ns
HandleTable roots before the timed loops: 1, after: 1
The static and module rows (+72…+96 ns) are the size of the resolution (73 ns) and are not numbers; the
constructor's +351 ns is (type.__call__ + __init__ + attribute store + __del__). Roots 1 → 1 shows ten
thousand Counter(10)s all released through __del__ → _pm_release (a mis-dispatched release would have
leaked silently). Android and androidNative also run this commonTest file; no proxy-cost rows were recorded
for them (missing, not zero).
The top-level property fix. A top-level val read used to cost +551…+587 ns over its boundary on desktop
(2.16–2.29x) against +6…+34 ns for the metaclass doing the same job for a class static. A standalone probe, net of
an empty call: plain module attribute 14 ns; PEP 562 module __getattr__ 214 ns; type-level __getattr__ hook
(the old shape) 569 ns. After moving to a per-module data descriptor:
| desktop, net of the boundary | before | after |
|---|---|---|
demo.calc.tally read |
+551…+587 ns (2.16–2.29x) | +14…+36 ns (1.03–1.07x) |
demo.calc.tally = 9 write |
+109…+136 ns (1.21–1.29x) | +44…+54 ns (1.08–1.11x) |
PythonProxyInstallTest passed unchanged across it, including the read-only refusal; nothing in the source
says which shape is fifty times dearer — the measurement found it.
The await fast path costs one coroutine frame:
| desktop | iOS simulator | |
|---|---|---|
_pm_invoke(h, (21,)) in the same coroutine loop, no await |
561–617 ns | 3025–3055 ns |
await a pure-Python coroutine that never suspends |
67–70 ns | 84.5–84.8 ns |
await demo.calc.doubleNow(21) through the generated async def |
618–647 ns | 3186–3239 ns |
| ratio, and what the wrapper adds | 1.03–1.10x, +24…+57 ns | 1.04–1.06x, +152…+185 ns |
asyncio.Futures constructed during the timed loops |
0 (asserted) | 0 (asserted) |
Install is a startup cost. For the 11-entry, 2-class fixture (306 lines / 12.4 kB):
| desktop | iOS simulator | wasmJs (338 lines, 14 052 chars) | |
|---|---|---|---|
PythonProxySource.render() |
226–271 µs | 606–664 µs | 186.63 µs |
Python3.exec of the result |
816–927 µs | 2481–2532 µs | 2155.50 µs |
install() end to end |
1.06–1.20 ms | 3.09–3.20 ms | 2342.14 µs |
Compiling and executing the Python is 75–80% of it; it scales with table size (ten times the surface ≈ tens of
ms, less than Py_Initialize).
All figures below were taken by UpcallOverheadTest (androidInstrumentedTest) at its old 3 000-call warmup,
two runs per emulator unless stated; they are kept as the record of how the per-call attach was found and
removed. The within-run differences and ratios are quotable; the absolute ns are not (they were read before
tier-up and are expected to fall at 100 000).
Per-call attach/detach (before):
| per upcall | API 26 | API 36 |
|---|---|---|
| instrumentation thread (ART already knows it) | 1307–1695 ns | 5307–6693 ns |
| Python worker thread, steady state | 59712–63293 ns | 19820–31219 ns |
| the attach/detach pair | 58405–61598 ns | 14514–24526 ns |
| upcall / downcall of the same shape, worker | 44.59–47.93x | 11.69–13.23x |
Attach once per thread (after, commit 409da6fc):
| per upcall | API 26 | API 36 |
|---|---|---|
| instrumentation thread | 1280–1327 ns | 2982–5086 ns |
| Python worker thread, steady state | 1209–1329 ns | 2301–3086 ns |
| worker minus instrumentation thread | −118 to +49 ns | −681 to −2000 ns |
| upcall / downcall of the same shape, worker | 0.99–1.09x | 2.00–2.34x |
Re-run unchanged over five full suites per emulator after 09bf2397 put commonTest on the device (so
UpcallBoundaryCostTest's several hundred thousand upcalls ran first and left the JIT warm — the observation the
warmup fix came from: the API 36 instrumentation row differs fourfold at the same warmup and emulator):
| re-run, 5 suites each | API 26 | API 36 |
|---|---|---|
| instrumentation thread | 1085–1273 ns | 1032–1229 ns |
| Python worker, steady state | 1249–1334 ns | 982–1219 ns |
| worker minus instrumentation | +43 to +249 ns | −50 to +12 ns |
| first upcall on a fresh worker | 88 250–242 375 ns | 41 208–611 416 ns |
On API 36 the claim sharpens (the gap is tight around zero). On API 26 a worker costs a little more (+43–249 ns against a ~1250 ns call) — not a return of the per-call attach. The first upcall on each worker still pays the attach in full (56–142 µs in the first round, 41–611 µs in the re-run): once per thread, not once per call. A worker that upcalls once gains nothing; one that upcalls in a loop is 20–50× better off. The structural assertion the test enforces — one ART thread per Python worker — is a count and unaffected by warmup.
Only what is really open; each with what proves the done part.
- Async proxies on wasmJs. Synchronous proxies run (
PythonProxyInstallTest10/11 onwasmJsNodeTest);import asyncioworks (WasmSelectorsImportTest); the default event loop cannot be built (§5.8). Open decision: whether the library ships a selector-free loop. SPEC U-5plannedon wasm. - Kotlin-side forced cancellation. Structurally impossible without
kotlinx.coroutines'Jobtree; only cooperativeensureActive()exists (§5.6). Not planned unless that dependency is taken. - A handle left by a dead loop. The Kotlin
finallyrelease is meant to cover a loop that stopped before its done callback ran; that case was never constructed and observed (unverified). - Silent exclusions without a KSP warning. A
suspendfunction type warns (§5.3); asuspend funwith an extension receiver or type parameters is still dropped silently by the shape checks (BindingPolicy.isExposedFunctionShape). The warning belongs there too. - Extension functions through KSP. KSP excludes them; the artifact walker binds them as receiver methods
(SPEC U-7
partial). - Kotlin calling a Python override. Not implemented (§4.4).
- Per-target gaps outside desktop. U-3/U-4 are asserted on desktop, with smoke coverage elsewhere
(
NativeSmokeTest,GeneratedAndroidTableTest); the native async tests have no recorded per-test androidNative result (§5.7); no proxy-cost rows for Android/androidNative (§7.5); the iOS sample cannot show the really-suspending demo (§5.8). BYTESmarshalling is per item untilPyBytes_AsStringAndSizeis bound (§3.3).- Incremental KSP needs per-file fragments (§2.7). Transitive and published-artifact discovery are unverified (§2.9).
- Native-image verification is manual (SPEC U-6, N-5); automation is planned.
- Proxy-cost re-measurement. §7.5's tables predate the 100 000 warmup; §7.6's absolutes predate it too.