diff --git a/docs/design/typedpython.md b/docs/design/typedpython.md index eaf3a02d..31e8a0f6 100644 --- a/docs/design/typedpython.md +++ b/docs/design/typedpython.md @@ -221,6 +221,34 @@ GC 추적), refcount 증감, 해제(`subtype_dealloc`)를 그대로 치르기 더 가려면 객체를 아예 만들지 않거나(가상 객체), 증감을 빼거나(refcount 생략), 한꺼번에 할당·해제해야 한다(영역 할당). 셋 다 `id()`, `__del__` 시점, `gc` 관측과 부딪칠 수 있다. +**실제 객체(N-11)의 생성 비용 (#125, 2026-11-05).** 프로파일(`benchmarks/typedpython/results/2026-10-25-binary-trees-alloc-profile.md`) +가운데 관측을 바꾸지 않고 줄일 수 있는 것은 다음과 같이 처리했다. +- 호출마다 스레드 상태를 한 번만 읽는다(#273). +- `tp_new_fixed` 는 새 객체의 슬롯을 `PyMember_SetOne` 대신 release store 로 직접 채운다. `SetOne` 이 + `Py_T_OBJECT_EX` 슬롯에 하는 일(객체 critical section 안에서 `Py_XNewRef(v)` 의 release store, 그 뒤 옛 값 + `Py_XDECREF`)에서 critical section 과 옛 값 해제만 뺀 것이다. 옛 값은 `tp_alloc` 이 0 으로 채웠으므로 NULL 이다. + critical section 을 빼도 되는 근거: `tp_alloc` 부터 마지막 슬롯 저장까지 스레드 상태를 놓는 호출도, eval + breaker 검사도 없어서 safepoint 가 없다. 그래서 stop-the-world 가 필요한 `gc.get_objects()` / + `gc.get_referrers()` 는 채우기가 끝나기 전에 이 객체를 다른 스레드에 줄 수 없다. 오히려 기존 critical + section 은 경합 시 스레드 상태를 놓을 수 있어서, 반쯤 채운 객체가 보이는 실행이 가능했다(그것도 인터프리터의 + `__init__` 실행과 같은 상태라 유효했다). 슬롯의 종류·쓰기 가능·오프셋 범위는 `tp_class_capture` 가 init 에서 + 확인한다. 참조 수: 슬롯은 `Py_NewRef` 로 자기 참조를 갖는다(이것을 빼는 변이는 `test_cgen_classes.py` 에서 + segfault 로 잡힌다). 측정(참고용, load 24~27): binary-trees `check(make(18))` 의 CPU 시간 중앙값이 develop + 대비 1.08배 빠르다. 같은 부하에서 벽시계 시간은 한 회차 안에서도 2~22초로 흔들려 쓸 수 없었다. + +하지 않은 것과 이유: +- **타입별 freelist.** 해제된 객체의 메모리를 붙잡으려면 클래스의 `tp_dealloc` 를 바꿔야 한다. 이것은 + 2026-10-25 결정(#125 댓글)에서 미뤘다: 클래스가 뒤에 `__del__`, `__weakref__`, 서브클래스를 얻으면 바뀐 dealloc 이 + 관측된다. free-threaded 빌드에서는 해제된 GC 객체의 메모리를 다른 스레드가 lock-free 로 읽을 수 있어서 + (`_Py_TryIncref` 계열) CPython 은 mimalloc 의 지연 해제(QSBR)에 기댄다. 확장이 따로 붙잡는 freelist 는 이 보장 + 밖에 있으므로, 이 결정을 다시 열 때 그 근거도 함께 써야 한다. +- **GC 추적을 늦추기/생략.** 아직 사이클을 만들 수 없는 노드(자식이 `None`)를 추적하지 않는 방식은 + `gc.is_tracked`, `gc.get_objects()` 로 관측된다. 그리고 인터프리터가 나중에 슬롯에 자기 자신을 넣으면 + (`PyMember_SetOne` 은 추적을 다시 켜지 않는다) 사이클이 수거되지 않는다. 컬렉션 횟수를 줄이는 방식(할당 계수 + 묶기, 임계값 조정)은 컬렉션 시점을 바꾼다. 둘 다 SPEC N-12 의 "GC 시점" 판단에 속하므로 오너 결정 사항이다. +- binary-trees 의 `main` 은 정책(#150)으로 컴파일되지 않고 순수하지도 않아서(`print`, `sys.argv`), 실제 객체를 + 아예 없애는 N-12b 영역이 이 벤치마크에는 적용되지 않는다. + **동일성의 정의.** 컴파일된 코드의 모든 실행은 **어떤 유효한 CPython 실행과 구별할 수 없어야 한다.** 구별은 관측으로만 할 수 있으므로, 설계는 "관측 지점" 목록과 "관측 지점에 닿기 전에 실제 객체로 되돌린다"는 규칙으로 이루어진다. CPython 자체가 정하지 않는 것(스레드 전환 시점)은 CPython 이 고를 수 있는 실행 중 diff --git a/python-multiplatform-ksp/src/main/python/typedpython/runtime/API.md b/python-multiplatform-ksp/src/main/python/typedpython/runtime/API.md index 9c30cdad..4d0e4f25 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/runtime/API.md +++ b/python-multiplatform-ksp/src/main/python/typedpython/runtime/API.md @@ -389,7 +389,8 @@ Slot access and the slow paths: int tp_field_set(PyObject *obj, PyMemberDef *m, PyObject *v); /* PyMember_SetOne: 0 / -1 */ int tp_setattr(PyObject *obj, PyObject *name, PyObject *v); /* STORE_ATTR (PyObject_SetAttr): 0 / -1 */ PyObject *tp_new_fixed(PyTypeObject *cls, PyMemberDef *const *slots, PyObject *const *values, Py_ssize_t n); - /* cls->tp_alloc(cls, 0) then set each slot (new references); NULL + MemoryError */ + /* cls->tp_alloc(cls, 0) then a release store of a new reference into each slot + (the fresh object is unreachable until it returns, #125); NULL + MemoryError */ int tp_is_exact(PyObject *obj, PyTypeObject *cls); /* Py_IS_TYPE */ FieldGet's slow path is `tp_getattr`, New's is `tp_global` + `tp_call` (the binding loaded before the arguments), IsExact's compares with `tp_global`; in a pure function every slow path is a deopt. diff --git a/python-multiplatform-ksp/src/main/python/typedpython/runtime/tp_runtime.h b/python-multiplatform-ksp/src/main/python/typedpython/runtime/tp_runtime.h index 8b9f54d8..845f42ec 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/runtime/tp_runtime.h +++ b/python-multiplatform-ksp/src/main/python/typedpython/runtime/tp_runtime.h @@ -2756,7 +2756,22 @@ static inline int tp_setattr(PyObject *obj, PyObject *name, PyObject *v) /* The observable result of a trivial __init__: allocate with cls's tp_alloc and set slots[i] to * values[i] (new references; the caller keeps its own). NULL with the error set on failure; the - * half-built object is released (its unset slots are NULL, which dealloc handles). */ + * half-built object is released (its unset slots are NULL, which dealloc handles). + * + * Issue #125: each slot is filled by a release store of a new reference, which is what + * PyMember_SetOne does for a Py_T_OBJECT_EX slot (FT_ATOMIC_STORE_PTR_RELEASE of Py_XNewRef(v) + * under the object's critical section, then Py_XDECREF of the old value), minus the critical section + * and the old value. Why that is the same result: + * - every slot was checked at capture (tp_class_capture): Py_T_OBJECT_EX, writable, offset inside + * tp_basicsize; so SetOne's READONLY and type dispatch are decided already; + * - tp_alloc zeroed the object, so the old value is NULL and there is nothing to release; + * - nothing between tp_alloc and the last store can reach a safepoint (no call that detaches the + * thread state, no eval-breaker check), so no stop-the-world (gc.get_objects, gc.get_referrers) + * can hand the object to another thread before it is filled. The critical section, which could + * detach when contended, protected nothing here. A reader that did find it (none can) would see + * NULL or the stored value, as with SetOne's release store. + * A NULL value (the generator never passes one) keeps SetOne's result: AttributeError for deleting + * an unset Py_T_OBJECT_EX slot. */ static inline PyObject *tp_new_fixed(PyTypeObject *cls, PyMemberDef *const *slots, PyObject *const *values, Py_ssize_t n) { @@ -2767,10 +2782,13 @@ static inline PyObject *tp_new_fixed(PyTypeObject *cls, PyMemberDef *const *slot return NULL; } for (i = 0; i < n; i++) { - if (PyMember_SetOne((char *)obj, slots[i], values[i]) < 0) { + if (values[i] == NULL) { + PyErr_SetString(PyExc_AttributeError, slots[i]->name); Py_DECREF(obj); return NULL; } + __atomic_store_n((PyObject **)((char *)obj + slots[i]->offset), Py_NewRef(values[i]), + __ATOMIC_RELEASE); } return obj; } diff --git a/python-multiplatform-ksp/src/test/python/test_cgen_classes.py b/python-multiplatform-ksp/src/test/python/test_cgen_classes.py index dfbeb005..4858212d 100644 --- a/python-multiplatform-ksp/src/test/python/test_cgen_classes.py +++ b/python-multiplatform-ksp/src/test/python/test_cgen_classes.py @@ -428,6 +428,20 @@ def test_gen_uses_only_the_runtime_helpers_for_slots(): assert "PyMember_" not in text and "tp_alloc" not in text and "->tp_dictoffset" not in text +def test_new_fixed_fills_its_fresh_object_with_direct_release_stores(): + """#125: a New allocates its object and fills every slot before control can reach a safepoint, so + no other thread holds the object yet. tp_new_fixed stores each new reference straight into the + slot (release store, as PyMember_SetOne's free-threaded store) instead of taking the member + protocol's per-object critical section for every slot.""" + from typedpython import cbuild + h = (cbuild.RUNTIME_DIR / "tp_runtime.h").read_text() + start = h.index("static inline PyObject *tp_new_fixed(") + body = h[start:h.index("\n}\n", start)] + assert "PyMember_SetOne" not in body and "CRITICAL_SECTION" not in body, body + assert "__atomic_store_n(" in body and "__ATOMIC_RELEASE" in body, body + assert "Py_NewRef(" in body, body # the slot owns its own reference + + # --- refcounts --------------------------------------------------------------------------------------------- def test_reference_counts_are_unchanged_over_many_calls(m):