From e7c9357024edcb1f7b3ba7173e9c2df788c2c851 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:01:53 +0900 Subject: [PATCH 1/3] Docs: Design virtual objects, refcount elision and region allocation (N-12) --- docs/SPEC.md | 20 ++++++++ docs/design/typedpython.md | 101 +++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/docs/SPEC.md b/docs/SPEC.md index 95e176b45..c797827c0 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -562,6 +562,26 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. compiled. A class outside these rules stays interpreted with a reason. Methods are not compiled yet. `Status: implemented (module-level functions; methods planned)`. +- **N-12** TypedPython object cost: virtual objects, refcount elision, region allocation (design §4.3.4). + Every execution of compiled code must be indistinguishable from some valid CPython execution. An object + of an N-11 class without `__del__` and without a `__weakref__` slot may live as a C struct (a virtual + object) and is turned into a real object (materialised) just before any observation point: + - leaving the function, or being stored into a global, a container or a real object's field; + - being passed to unknown code, or reaching `id()` or the default `hash`/`repr`; + - **any call into unknown code while it is alive**, because `gc.get_objects()` and `tracemalloc` see it + without being passed it. At such a call every live virtual object is materialised, in allocation order. + `is`, `type()` and `isinstance` on virtual objects are decided statically. An increment/decrement pair is + elided only when another owned reference keeps the object alive throughout and no unknown code runs in + between, so every refcount that can be observed is exact. Virtual objects whose count is unbounded live in + a region tied to one activation (the innermost scope that bounds their lifetime) and are freed together; + real objects are never placed in a region. A failed region allocation raises `MemoryError` where CPython + would. The verifier proves non-escape, materialisation before every observation point, a keeping + reference for every elided pair, and region lifetimes; anything unproved compiles as N-11. Differential + tests include `id`, `gc.get_objects` count and order, `gc.get_referrers`, `sys.getrefcount`, + `tracemalloc`, `is` between virtual and real objects, and `MemoryError` during construction. + Needs a decision before implementation (design §4.3.4 마): whether the timing of garbage collection + is part of the identity. Not allocating changes when collections run, and so when finalisers of + unrelated cyclic garbage run. `Status: planned (design)`. --- diff --git a/docs/design/typedpython.md b/docs/design/typedpython.md index ff3bc4639..fbf40964b 100644 --- a/docs/design/typedpython.md +++ b/docs/design/typedpython.md @@ -203,6 +203,107 @@ Gradle 태스크는 입력·출력을 정확히 선언해 up-to-date 판정과 프레임이 끼면 그 프레임은 세지 않는다. 테스트는 두 경우 모두 "둘 다 예외를 내고 죽지 않는다" 만 단언하고 깊이를 기록한다(`test_recursion.py`). +### 4.3.4 객체 비용: 가상 객체, refcount 생략, 영역 할당 (SPEC N-12, 설계) + +사용자 지시 (2026-10-03): "Rust 쪽으로 더 가는 방법을 적용하되 cpython과 동일성을 보장할 방법을 찾아." + +**왜 필요한가.** 숫자·배열 코드는 이미 Rust 근처다(nbody 31x, Rust 의 약 2배). 객체가 많은 코드는 그렇지 +않다. binary-trees 는 1.7x 인데, 컴파일된 코드도 노드마다 CPython 할당(`tp_alloc`: 0 채우기, GC 헤더, +GC 추적), refcount 증감, 해제(`subtype_dealloc`)를 그대로 치르기 때문이다(#125, 아직 프로파일로 확정 전). +더 가려면 객체를 아예 만들지 않거나(가상 객체), 증감을 빼거나(refcount 생략), 한꺼번에 할당·해제해야 +한다(영역 할당). 셋 다 `id()`, `__del__` 시점, `gc` 관측과 부딪칠 수 있다. + +**동일성의 정의.** 컴파일된 코드의 모든 실행은 **어떤 유효한 CPython 실행과 구별할 수 없어야 한다.** +구별은 관측으로만 할 수 있으므로, 설계는 "관측 지점" 목록과 "관측 지점에 닿기 전에 실제 객체로 되돌린다"는 +규칙으로 이루어진다. CPython 자체가 정하지 않는 것(스레드 전환 시점)은 CPython 이 고를 수 있는 실행 중 +하나를 고른 것으로 본다. 컴파일된 코드는 GIL 을 놓지 않으므로, 컴파일 구간 안에서 다른 스레드가 끼어드는 +실행은 생기지 않는다. 이는 CPython 이 그 구간에서 전환하지 않은 실행과 같다. + +#### 가. 가상 객체 (부분 탈출 분석 + 지연 실체화) + +PyPy·GraalVM 과 같은 방식이다. N-11 클래스의 객체를 PyObject 대신 C 구조체(필드 값만)로 두고, 관측 +지점에 닿기 직전에 실제 PyObject 로 만든다(실체화). 실체화된 뒤로는 인터프리터와 같은 객체이므로 결과가 같다. + +- **대상 클래스.** N-11 조건에 더해 `__del__` 이 없고 `__slots__` 에 `__weakref__` 가 없는 클래스만. 이 둘이 + 있으면 객체의 죽는 시점이 사용자 코드로 관측된다. `base = object` 이고 슬롯이 고정이므로 weakref 는 + `__weakref__` 슬롯으로만 생긴다. +- **관측 지점(실체화 트리거).** 아래에 닿는 가상 객체는 그 직전에 실체화한다. + + | 관측 | 이유 | + |---|---| + | 함수 밖으로 반환, 전역·컨테이너·실객체 필드에 저장 | 컴파일 구간 밖에서 보인다 | + | 미지 코드 호출의 인자, `id()`, 기본 `hash()`·`repr()` | 객체 자체나 주소가 드러난다 | + | **살아 있는 동안 일어나는 모든 미지 코드 호출** | 인자로 넘기지 않아도 `gc.get_objects()`·`gc.get_referrers()`·`tracemalloc` 이 존재와 메모리를 본다. 그래서 그 시점에 살아 있는 가상 객체는 **전부** 실체화한다 | + | deopt | 순수 함수의 deopt 는 인자부터 인터프리터로 다시 실행하므로 내부 가상 객체는 버리면 된다. 비순수 함수는 효과 뒤 deopt 가 없다(N-9) | + + `is`, `type(x)`, `isinstance` 는 관측 지점이 아니다. 두 가상 객체의 `is` 는 할당 지점 동일성으로 정적으로 + 정해지고, 가상 객체와 실객체는 같을 수 없다. 타입은 정적으로 안다. +- **실체화 순서.** 할당 순서대로 만든다. `gc.get_objects()` 의 순서도 관측되기 때문이다. 실체화는 도달 + 가능한 가상 그래프 전체를 만들고 필드를 채운 뒤 진행한다. +- **효과.** 가상 객체는 미지 코드 호출이 없는 구간에서만 산다. 트리를 만들고 바로 소비하는 코드(binary-trees + 의 `check(make(d))`)는 그런 구간이다. `print` 를 섞는 루프는 매 반복 실체화하므로 지금과 같다. + +#### 나. refcount 생략 + +- 같은 객체의 증가·감소 쌍을 빼는 것은 **그 사이 내내 다른 소유 참조가 그 객체를 살려 둔다**는 것과 + **그 사이에 미지 코드 호출이 없다**는 것을 IR 에서 증명할 때만 한다. 감소가 0 에 닿을 수 있는 지점은 + 해제 지점이므로 빼지 않는다. +- 첫 대상은 빌린 읽기다. 예를 들어 `check(node.left)` 는 지금 `FieldGet` 이 새 참조를 만들어 넘기고 돌아와 + 놓는다. 바깥 객체가 소유되어 있고, 그 슬롯이 그동안 쓰이지 않고, 사용자 코드가 돌지 않으면 빌린 참조로 + 넘길 수 있다. +- `sys.getrefcount` 는 호출로만 관측되고, 호출은 위 조건으로 막혀 있으므로 관측되는 refcount 는 늘 정확하다. + 다른 스레드는 GIL 때문에 그 사이를 보지 못한다. free-threaded 빌드는 범위 밖이다(이 모듈은 GIL 을 다시 켠다). + +#### 다. 영역(arena) 할당 + +- 개수 상한이 있는 가상 객체는 C 스택에 둔다. 재귀 구조처럼 개수에 상한이 없는 가상 객체(`make(d)` 의 트리)는 + **활성(activation) 하나에 묶인 영역**에 할당하고, 그 활성이 끝날 때 한꺼번에 놓는다. +- **실객체는 영역에 두지 않는다.** CPython 이 실객체를 하나씩 해제하기 때문이다(`tp_free`). 영역에는 가상 + 객체만 있고, 실체화는 영역 밖에 실객체를 새로 만든다. +- **수명.** 영역은 수명을 감싸는 가장 안쪽 범위(루프 한 반복 등)에 둔다. CPython 이 일찍 놓는 객체를 영역이 + 함수 끝까지 붙잡으면 최대 메모리가 커진다. 그 차이는 `tracemalloc` 같은 관측에서 드러날 수 있지만, + 관측은 미지 코드 호출로만 일어나고 그 직전에 실체화하므로, 남은 차이는 프로세스 메모리 총량뿐이다. +- **할당 실패.** 영역 할당이 실패하면 CPython 이 그 할당에서 냈을 `MemoryError` 를 같은 지점에서 낸다. +- **함수 경계.** `make` 가 만든 트리를 호출자가 바로 소비하면, 호출자의 영역에 쓰는 `make` 의 변형을 따로 + 만든다. 할당 지점 특수화이고, 공개 진입점(wrapper)은 늘 실체화한 객체를 돌려준다. + +#### 라. 증명과 시험 + +- **증명(N-9 확장).** 검증기가 증명하는 것은 넷이다. 가상 객체가 영역·스택 밖으로 새지 않는다. 모든 관측 + 지점 앞에 실체화가 있다. 생략한 refcount 쌍마다 살려 두는 참조가 있다. 영역 수명이 그 안의 모든 참조 + 수명을 감싼다. 증명하지 못하면 N-11 경로(실객체)로 컴파일한다. +- **차등 시험.** 관측 연산을 일부러 넣은 경우를 인터프리터와 비교한다. 대상은 다음과 같다. + - 가상 객체에 대한 `id()`, 기본 `hash`·`repr`. + - 살아 있는 동안 부른 함수 안에서의 `gc.get_objects()` 수·순서, `gc.get_referrers`. + - 호출 경계의 `sys.getrefcount`, `tracemalloc` 스냅샷. + - 가상 객체와 실객체 사이의 `is`. + - 생성 도중 `MemoryError`. 시험용 할당기 훅으로 일으킨다. + - `__del__`·`__weakref__` 가 있는 클래스. 대상에서 빠지는지만 본다. +- **변형 시험.** 미지 호출 앞 실체화를 빼면 `gc.get_objects` 시험이 실패해야 한다. refcount 쌍의 살려 두는 + 참조 조건을 빼면 해제 시점 시험이 실패해야 한다. + +#### 마. 동일성을 지키려면 결정이 필요한 점 (사용자 결정, 리더 경유) + +1. **GC 수집 시점.** CPython 3.12 이후 세대 수집은 GC 추적 객체 할당 수로 예약되고, 다음 eval breaker 검사에서 + 실행된다. 가상 객체는 할당되지 않으므로 수집이 늦어지거나 빠진다. 그러면 **다른 곳의** 순환 쓰레기의 + `__del__`, weakref 콜백, `gc.callbacks` 가 실행되는 시점이 옮겨간다. + - (a) 지키는 방법: 가상 객체를 만들 때마다 GC 할당 카운터를 실제 할당과 같이 올리고, 수집이 예약되면 그 + 자리에서 실행한다. 둘 다 CPython 내부 구조체(비공개 API)를 쓴다. 임베디드 CPython 판이 고정이라 가능은 + 하지만 판이 바뀔 때마다 다시 확인해야 한다. + - (b) 범위 밖으로 두는 방법: 언어 레퍼런스는 수집 시점을 구현이 정하도록 허용하고(데이터 모델 §3.1), + CPython 도 3.13→3.14 에서 증분 GC 로 바꾸며 시점이 달라졌다. + - 사용자가 정할 일이다. +2. **이미 있는 차이: eval breaker 를 검사하지 않는다(N-8 부터).** 컴파일된 코드는 루프 안에서 eval breaker 를 + 보지 않는다. 그래서 긴 컴파일 루프 동안 넷이 미뤄진다: 시그널(`KeyboardInterrupt`), 예약된 GC, pending + call, **다른 스레드로의 GIL 넘김.** 앞의 셋은 시점 차이지만, 마지막은 다른 스레드가 굶는 동작 차이다. + 루프 뒤 간선(back-edge)과 호출에서 eval breaker 를 검사하면 고쳐진다. 비용 측정과 별도 이슈가 필요하다. + 1번의 (a) 도 이 검사 위에서만 성립한다. +3. **이미 있는 차이: 추적·모니터링.** 컴파일된 함수는 `sys.settrace`·`sys.setprofile`·`sys.monitoring` + 이벤트를 내지 않는다. 진입에서 추적이 켜져 있으면 인터프리터로 실행하는 방법(deopt 경로 재사용)을 제안한다. + 이것도 별도 이슈다. + +이 셋은 N-12 이전부터 있는 경계도 포함한다. N-12 구현은 1번 결정이 난 뒤에 시작한다. + ### 4.4 Kotlin 경계의 직접 호출 이 플랫폼에서만 가능한 이점이다. From d4ec1e47dd6af0b3cae3e5d4ddd8ae2bab1e9dd1 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:22:50 +0900 Subject: [PATCH 2/3] Docs: GC collection timing is outside N-12's identity (user decision) --- docs/SPEC.md | 9 ++++++--- docs/design/typedpython.md | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index c797827c0..e405c03fc 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -579,9 +579,12 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. reference for every elided pair, and region lifetimes; anything unproved compiles as N-11. Differential tests include `id`, `gc.get_objects` count and order, `gc.get_referrers`, `sys.getrefcount`, `tracemalloc`, `is` between virtual and real objects, and `MemoryError` during construction. - Needs a decision before implementation (design §4.3.4 마): whether the timing of garbage collection - is part of the identity. Not allocating changes when collections run, and so when finalisers of - unrelated cyclic garbage run. `Status: planned (design)`. + **Outside the identity: when the cyclic garbage collector runs** (user decision, 2026-10-04). Not + allocating changes when collections are scheduled, and so when finalisers, weakref callbacks and + `gc.callbacks` of *unrelated* cyclic garbage run. The language reference leaves this to the + implementation ("An implementation is allowed to postpone garbage collection or omit it + altogether", Data model §3.1), and CPython itself changed it in 3.14 (incremental GC). Everything + else above stays exact. `Status: planned (design)`. --- diff --git a/docs/design/typedpython.md b/docs/design/typedpython.md index fbf40964b..1ebee9d25 100644 --- a/docs/design/typedpython.md +++ b/docs/design/typedpython.md @@ -292,7 +292,7 @@ PyPy·GraalVM 과 같은 방식이다. N-11 클래스의 객체를 PyObject 대 하지만 판이 바뀔 때마다 다시 확인해야 한다. - (b) 범위 밖으로 두는 방법: 언어 레퍼런스는 수집 시점을 구현이 정하도록 허용하고(데이터 모델 §3.1), CPython 도 3.13→3.14 에서 증분 GC 로 바꾸며 시점이 달라졌다. - - 사용자가 정할 일이다. + - **결정 (2026-10-04, 사용자): (b).** GC 수집 시점은 동일성 범위에서 뺀다. 그 밖의 모든 관측은 정확히 지킨다. 2. **이미 있는 차이: eval breaker 를 검사하지 않는다(N-8 부터).** 컴파일된 코드는 루프 안에서 eval breaker 를 보지 않는다. 그래서 긴 컴파일 루프 동안 넷이 미뤄진다: 시그널(`KeyboardInterrupt`), 예약된 GC, pending call, **다른 스레드로의 GIL 넘김.** 앞의 셋은 시점 차이지만, 마지막은 다른 스레드가 굶는 동작 차이다. @@ -302,7 +302,7 @@ PyPy·GraalVM 과 같은 방식이다. N-11 클래스의 객체를 PyObject 대 이벤트를 내지 않는다. 진입에서 추적이 켜져 있으면 인터프리터로 실행하는 방법(deopt 경로 재사용)을 제안한다. 이것도 별도 이슈다. -이 셋은 N-12 이전부터 있는 경계도 포함한다. N-12 구현은 1번 결정이 난 뒤에 시작한다. +이 셋은 N-12 이전부터 있는 경계도 포함한다. 1번이 결정되어 N-12 구현을 시작한다. 2번은 #141(1단계 완료), 3번은 #142. ### 4.4 Kotlin 경계의 직접 호출 From 542c42bea4526a13da12aeda8af94da241f122e7 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:29:22 +0900 Subject: [PATCH 3/3] Docs: Bring the TypedPython design up to date (N-11 landed, #141 phase 1) --- docs/design/typedpython.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/design/typedpython.md b/docs/design/typedpython.md index 1ebee9d25..ad7fd4578 100644 --- a/docs/design/typedpython.md +++ b/docs/design/typedpython.md @@ -174,10 +174,11 @@ Gradle 태스크는 입력·출력을 정확히 선언해 up-to-date 판정과 순수 함수로 남아 i64 경로를 쓸 수 있다. - **생성:** `__init__` 이 매개변수를 각 슬롯에 한 번씩 대입하기만 하면 `C(a, b)` 를 할당 + 슬롯 채우기로 내린다. 그 밖의 `__init__` 은 객체 호출로 남는다. -- **메서드:** `self` 를 `type(self) is C` 로 가드한 함수로 컴파일한다. -- **IR 확장(예정):** `FieldGet(obj, cls, field)`, `FieldSet(obj, cls, field, value)`, `New(cls, args)`, - 그리고 정확한 타입 가드 `IsExact(obj, cls)` 조건. 검증기는 가드 없는 필드 접근, 슬롯 밖 필드, 생성자 규칙 - 위반을 거부한다. +- **메서드(예정):** `self` 를 `type(self) is C` 로 가드한 함수로 컴파일한다. 아직 구현 전이다. +- **IR (구현됨, #124):** `FieldGet`, `FieldSet`, `New`, `IsExact`, `CheckExact`, `Is`, `ClassDecl`, `Param.cls`. + 검증기는 정확한 타입 증명이 없는 필드 접근, 슬롯 밖 필드, 생성자 규칙 위반을 거부한다. +- **초기화 뒤 변경 (구현됨):** 슬롯 접근마다 정확한 타입과 클래스의 `tp_version_tag` 를, 생성마다 전역 바인딩을 + 확인하고, 다르면 일반 경로(비순수) 또는 deopt(순수)로 간다. 세부는 SPEC N-11. 검토하고 기각한 경로: @@ -187,7 +188,7 @@ Gradle 태스크는 입력·출력을 정확히 선언해 up-to-date 판정과 - **LPython** — 업스트림 활동이 LFortran 쪽으로 옮겨간 것으로 보인다. - **Rust 생성** — 위. -### 4.3.3 재귀 (SPEC N-8, #57) +### 4.3.3a 재귀 (SPEC N-8, #57) 호출 사이클(직접·상호 재귀)은 컴파일한다. 컴파일된 프레임에는 파이썬 프레임이 없으므로 모든 컴파일 함수의 진입에서 `tp_enter_call()`, 모든 출구(ok·error·deopt)에서 `tp_leave_call()` 을 부른다. 진입은 @@ -296,8 +297,10 @@ PyPy·GraalVM 과 같은 방식이다. N-11 클래스의 객체를 PyObject 대 2. **이미 있는 차이: eval breaker 를 검사하지 않는다(N-8 부터).** 컴파일된 코드는 루프 안에서 eval breaker 를 보지 않는다. 그래서 긴 컴파일 루프 동안 넷이 미뤄진다: 시그널(`KeyboardInterrupt`), 예약된 GC, pending call, **다른 스레드로의 GIL 넘김.** 앞의 셋은 시점 차이지만, 마지막은 다른 스레드가 굶는 동작 차이다. - 루프 뒤 간선(back-edge)과 호출에서 eval breaker 를 검사하면 고쳐진다. 비용 측정과 별도 이슈가 필요하다. - 1번의 (a) 도 이 검사 위에서만 성립한다. + 루프 뒤 간선(back-edge)과 호출에서 eval breaker 를 검사하면 고쳐진다. + **1단계 구현 (#141):** 루프와 함수 진입에서 폴링하고, 일정 횟수마다 모듈의 `lambda: None` 을 불러 CPython 자기 + eval breaker 처리를 그대로 돌린다. 배열 copy-in·전역 스냅샷을 쥔 함수가 실행 중이면 미룬다. 그 함수 안에서의 + GIL 넘기기는 2단계(#154)이고, 방식은 결정 대기(#152). 3. **이미 있는 차이: 추적·모니터링.** 컴파일된 함수는 `sys.settrace`·`sys.setprofile`·`sys.monitoring` 이벤트를 내지 않는다. 진입에서 추적이 켜져 있으면 인터프리터로 실행하는 방법(deopt 경로 재사용)을 제안한다. 이것도 별도 이슈다.