RICCILAB
> projects/study-gpt/shape-contracts-before-tensors
> STUDY-GPT|STATUS: RELEASED|

Stage 05 — Shape는 주석이 아니다: tensor 전의 실행 계약

Vector·Matrix·embedding의 shape와 parameter·graph 비용을 가장 작은 신뢰 경계에서 강제한다.

_Python

PyTorch에서는 shape가 맞지 않으면 대개 tensor kernel이 즉시 실패한다. 순수 Python의 list[list[Value]]에는 그런 실행 계약이 없다. zip()은 짧은 쪽에 맞춰 조용히 잘리고, ragged matrix는 여러 연산을 통과한 뒤에야 실패할 수 있다. 잘못된 값이 정상 결과처럼 앞으로 진행할 수 있다는 점이 가장 큰 위험이다.

코드: Stage 05 — commit f0f4f3c

테스트: Stage 05 테스트

이번 단계가 답할 질문

tensor 구현 전에도 shape를 실행 시점의 강제 계약으로 검사해야 할까? 그렇다면 Vector, Matrix, embedding lookup, parameter budget 중 어디에서 실패시켜야 Blast Radius가 가장 작을까?

처음 세운 가설

tensor 구현 전에도 shape를 실행 시점의 강제 계약으로 검사하는 게 맞음. 오히려 순수 Python 단계에서 더 중요하다.

PyTorch에선 shape가 틀리면 대개 연산 커널이 실패하지만 list[list[Value]] 기반 구현은 잘못된 shape가 한동안 살아남을 수 있다. zip()이 짧은 쪽에 맞춰 조용히 잘라버리거나 ragged matrix가 몇 단계 뒤에서야 터지거나 broadcasting 비슷한 동작이 우연히 성립하는 식으로. 이런 건 계산 결과가 틀린 채 전진할 수 있어서 Blast Radius가 크다고 볼 수 있다.

결국 유효하지 않은 객체는 생성하지 않고, 유효하지 않은 연산은 시작하지 않고, 감당할 수 없는 모델은 할당하지 않는다.

모든 내부 루프에서 shape를 반복 검사할 필요는 없음. 그러면 원리를 보여주는 코드가 경비실만 잔뜩 달린 건물처럼 될 수 있다.

신뢰 경계에서 한 번 강하게 검사하고, 내부에서는 불변식을 신뢰하게 하는 것. “입력 shape가 틀림”과 “정상 shape지만 자원 예산을 넘음”은 원인과 대응이 다르니 구분 가치가 있다.

계약실패 위치
원소와 차원의 유효성Vector / Matrix 생성자
객체 사이 shape 관계vector add·matmul 진입점
embedding table 구조Embedding 생성자
token 입력 유효성embedding forward 진입점
parameter 비용allocation 전 EmbeddingPlan
실행별 계산 비용forward 또는 연산 진입점

예외는 두 종류만 추가한다

  • ShapeError: 입력이나 config 사이의 관계를 고쳐야 한다.
  • ResourceBudgetError: shape는 합법이지만 상한이나 model 크기를 바꿔야 한다. 원소 type, 비유한 값, token 범위에는 이미 의미가 분명한 TypeError, ValueError, IndexError를 사용한다. 거대한 예외 계층을 만들지 않는다.

intrinsic shape는 생성자가 확정한다

Vector는 생성될 때 길이 DD를 확정한다.

shape(v)=(D)\operatorname{shape}(v)=(D)

Matrix는 모든 row의 길이가 같은지 먼저 검사한 뒤에만 Value를 만든다.

ARM×Ki,  Ai=KA\in\mathbb{R}^{M\times K} \Longleftrightarrow \forall i,\;|A_i|=K

ragged matrix는 아직 shape가 정해지지 않은 값이다. matmul까지 전달하지 않고 생성 자체를 거부한다.

relational shape는 연산 전에 검사한다

두 vector의 덧셈은 길이가 같을 때만 정의한다.

x,yRDx+yRDx,y\in\mathbb{R}^{D} \Rightarrow x+y\in\mathbb{R}^{D}

길이가 (3,)(2,)라면 Python zip()은 두 항만 계산한다. Stage 05는 이를 silent truncation으로 보고 실패시킨다.

matrix multiplication의 계약은 다음과 같다.

ARM×K,BRK×NABRM×NA\in\mathbb{R}^{M\times K},\quad B\in\mathbb{R}^{K\times N} \Rightarrow AB\in\mathbb{R}^{M\times N}

실행한 정상 예제는 다음 결과를 만들었다.

[1234][5678]=[19224350]\begin{bmatrix}1&2\\3&4\end{bmatrix} \begin{bmatrix}5&6\\7&8\end{bmatrix} = \begin{bmatrix}19&22\\43&50\end{bmatrix}

ragged matrix, (3,) + (2,), (1,2) @ (1,2)는 모두 첫 스칼라 연산 전에 ShapeError로 끝났다.

정상 matmul과 shape 실패
정상 matmul과 shape 실패

matmul graph 비용도 연산 전에 안다

길이 KK의 dot product는 KK번의 곱셈과 K1K-1번의 덧셈을 만든다.

Pdot=K+(K1)=2K1P_{\mathrm{dot}}=K+(K-1)=2K-1

따라서 M×KM\times KK×NK\times N matrix의 matmul이 만드는 새 graph node 수는

Pmatmul=MN(2K1)P_{\mathrm{matmul}}=MN(2K-1)

이다. 실행한 2×22\times2 예제는 22(221)=122\cdot2\cdot(2\cdot2-1)=12개다. 상한 11이면 곱셈을 시작하지 않고, 상한 12이면 계산한다.

token과 position embedding shape

Stage 04의 vocabulary 크기는 V=257V=257이다. context 길이를 CC, embedding width를 DD라 하면

WtokenRV×D,WpositionRC×DW_{\mathrm{token}}\in\mathbb{R}^{V\times D}, \qquad W_{\mathrm{position}}\in\mathbb{R}^{C\times D}

이다. 위치 tt의 출력은 두 row의 합이다.

Et=Wtoken[idt]+Wposition[t],ERT×DE_t=W_{\mathrm{token}}[\operatorname{id}_t] +W_{\mathrm{position}}[t], \qquad E\in\mathbb{R}^{T\times D}

실행 config는 V=257V=257, C=8C=8, D=4D=4, 입력 길이는 T=3T=3이다.

token weight shape    = (257, 4)
position weight shape = (8, 4)
output shape          = (3, 4)

token ID 전체와 TCT\le C를 확인한 뒤에만 덧셈을 시작한다. 두 번째 ID가 잘못됐는데 첫 번째 row만 계산하는 부분 실행은 허용하지 않는다.

allocation 전에 parameter 수를 계산한다

Ptoken=VD,Pposition=CD,Pembedding=(V+C)D\begin{aligned} P_{\mathrm{token}} &= VD,\\ P_{\mathrm{position}} &= CD,\\ P_{\mathrm{embedding}} &= (V+C)D \end{aligned}

실행 config를 대입하면 다음과 같다.

2574+84=1028+32=1060257\cdot4+8\cdot4=1028+32=1060

EmbeddingPlan은 이 값을 정수 계산만으로 먼저 구한다. 상한 1059에서 실패한 뒤 initializer 호출 횟수가 0인지 확인했다.

shape와 parameter 사전 계산
shape와 parameter 사전 계산

allocation 전 budget 실패
allocation 전 budget 실패

이 상한은 logical parameter 개수다. Python Value에는 float 외에도 gradient, label, parent tuple과 객체 header가 있으므로 1060이 실제 memory byte 수를 뜻하지 않는다. byte 단위 memory 계획은 이후 별도로 측정해야 한다.

forward의 신뢰 경계

embedding forward가 새로 만드는 덧셈 node 수는 다음과 같다.

Pembedding forward=TDP_{\mathrm{embedding\ forward}}=TD

이번 실행은 34=123\cdot4=12개다. public forward는 token ID 전체, sequence shape, graph budget을 한 번 검증한다. 검증이 끝난 내부 loop는 private trusted add로 조립하며 같은 검사를 반복하지 않는다.

비용과 Blast Radius

실패차단 위치늦게 발견할 때의 문제
ragged rowMatrix 생성자뒤 연산마다 row 길이를 추측
vector 길이 불일치Vector.add()zip() silent truncation
matmul dimension 불일치Matrix.matmul()일부 곱셈 뒤 실패
weight/config 불일치Embedding 생성자잘못된 token 의미 전파
invalid token IDforward 전체 입력 검사부분 graph 생성
parameter 초과EmbeddingPlan큰 Python object allocation
graph node 초과연산 진입점CPU·memory 소비 뒤 실패

GPU와 NPU는 아직 사용하지 않는다. 이 shape와 parameter 이름은 나중에 PyTorch port가 따라야 할 reference specification이다.

직접 실행한 결과

python .\stages\stage_05_shape_contracts.py
python -m unittest tests.test_stage_05_shape_contracts -v
  • token weight shape: (257, 4)
  • position weight shape: (8, 4)
  • output shape: (3, 4)
  • embedding parameter: 1060
  • budget 실패 뒤 initializer 호출: 0
  • 정상 matmul output: (2, 2)
  • Stage 05 테스트 13개 통과
  • 전체 테스트 57개 통과
  • sanity check: PASS
    Stage 05 테스트 13개 통과
    Stage 05 테스트 13개 통과

이번 Stage가 증명한 것

최초 가설은 맞았다. tensor가 없어도 shape는 실행 시점에 강제해야 하는 계약이며, 순수 Python에서는 silent truncation과 ragged data를 막기 위해 더 중요하다.

가장 작은 Blast Radius를 만드는 순서는 intrinsic shape 확정, weight/config 결합, relational shape 검증, graph 비용 검증, allocation 전 parameter budget 검증이다.

Stage 06의 질문은 “큰 logit에서 확률이 NaN이 되지 않았다는 사실만으로 수치적으로 안정하다고 말할 수 있는가?”다.

EOF 2026-07-11