Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,29 @@ 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.
**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)`.

---

Expand Down
114 changes: 109 additions & 5 deletions docs/design/typedpython.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

검토하고 기각한 경로:

Expand All @@ -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()` 을 부른다. 진입은
Expand All @@ -203,6 +204,109 @@ 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 로 바꾸며 시점이 달라졌다.
- **결정 (2026-10-04, 사용자): (b).** GC 수집 시점은 동일성 범위에서 뺀다. 그 밖의 모든 관측은 정확히 지킨다.
2. **이미 있는 차이: eval breaker 를 검사하지 않는다(N-8 부터).** 컴파일된 코드는 루프 안에서 eval breaker 를
보지 않는다. 그래서 긴 컴파일 루프 동안 넷이 미뤄진다: 시그널(`KeyboardInterrupt`), 예약된 GC, pending
call, **다른 스레드로의 GIL 넘김.** 앞의 셋은 시점 차이지만, 마지막은 다른 스레드가 굶는 동작 차이다.
루프 뒤 간선(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 경로 재사용)을 제안한다.
이것도 별도 이슈다.

이 셋은 N-12 이전부터 있는 경계도 포함한다. 1번이 결정되어 N-12 구현을 시작한다. 2번은 #141(1단계 완료), 3번은 #142.

### 4.4 Kotlin 경계의 직접 호출

이 플랫폼에서만 가능한 이점이다.
Expand Down
Loading