「Python과 Jupyter 연구환경 구축」에서 프로젝트별 Python 환경과 계산 실행을 확인했습니다. 이번에는 MacBook의 GPU로 계산과 작은 학습을 실행해보기로 했습니다. GPU는 많은 숫자에 같은 종류의 계산을 반복하는 작업에 활용됩니다. PyTorch와 MLX는 이런 계산과 AI 모델 학습을 코드로 구성하는 도구입니다. 두 도구의 GPU 계산이 실제로 동작하는지, 결과는 믿을 만한지 작은 문제부터 확인했습니다.
1. 별도 프로젝트와 두 실행 방식
실험은 ~/Developer/research-apple-silicon-ai에 만들었습니다. 앞선 프로젝트에서 사용하던 패키지 구성을 유지하면서 새 도구를 시험하기 위해 폴더를 분리했습니다. 이 폴더 안의 .venv에는 이번 실험용 Python과 패키지가 들어가고, uv.lock에는 실제로 선택된 패키지 버전이 기록됩니다.
uv init --bare --vcs none --python 3.13.16 \
"$HOME/Developer/research-apple-silicon-ai"
cd "$HOME/Developer/research-apple-silicon-ai"
uv add torch mlx numpy
실제로 설치된 도구와 역할은 다음과 같습니다.
| 도구 | 설치 버전 | 이번 실험에서 하는 일 |
|---|---|---|
| Python | 3.13.16 | 실험 코드를 실행하는 기반 |
| PyTorch | 2.14.1 | GPU 계산과 작은 모델의 학습 |
| MLX / mlx-metal | 0.32.3 | Apple Silicon 계산 도구와 함께 설치된 GPU 실행용 패키지 |
| NumPy | 2.5.3 | GPU 결과와 대조할 CPU 계산 |
pyproject.toml에는 이 프로젝트에서 허용할 Python 버전을 >=3.13.16,<3.14로 적었습니다. 이는 3.13.16 이상이면서 3.14보다 낮은 버전이라는 뜻입니다. 이번에 확인한 3.13 계열 안에서 실험을 유지하고, 아직 시험하지 않은 3.14 계열로 넘어가는 일을 막기 위한 선택입니다. PyTorch와 MLX가 반드시 이 범위만 지원한다는 의미는 아닙니다. 실제 사용 버전은 .python-version에도 3.13.16으로 기록했습니다.
소스 코드는 로컬 프로젝트에 두고, 생성 결과는 results에 저장합니다. 결과 폴더는 .gitignore에 넣어 이후 Git으로 코드를 관리할 때 자동으로 함께 포함되지 않도록 했습니다.
PyTorch에서는 숫자를 담은 배열을 텐서라고 부릅니다. 이 텐서를 mps라는 계산 장치로 지정하면 Mac의 GPU를 사용하는 경로를 요청합니다. MPS는 PyTorch에서 Apple GPU를 이용하는 방식입니다. MLX는 CPU와 GPU가 같은 메모리를 공유하는 Apple Silicon의 구조를 활용하며, 연산마다 CPU 또는 GPU 실행 경로를 선택할 수 있습니다. 코드에서 이 경로를 지정할 때 사용하는 스트림은 여기서 “어느 장치에서 어떤 순서로 실행할지 정하는 경로”로 이해하면 됩니다. 이번에는 두 도구 모두 GPU를 직접 지정했습니다. PyTorch MPS 설명과 MLX 통합 메모리 설명을 참고했습니다.
2. 설치 상태와 GPU 접근 상태 구분
설치에 성공했다고 바로 GPU를 사용할 수 있는 것은 아니었습니다. PyTorch의 is_built()는 설치된 패키지에 MPS 기능이 들어 있는지, is_available()은 현재 실행 환경에서 그 기능을 사용할 수 있는지 확인합니다. 제한된 명령 실행 환경에서는 첫 확인만 참이었고 두 번째는 거짓이었습니다. MLX도 Apple GPU에 접근하는 Metal 장치가 없다는 오류로 불러오지 못했습니다. 이 결과만으로 MacBook에서 GPU 연산을 사용할 수 없다고 판단하지 않고, 일반 macOS 로그인 세션에서 같은 프로젝트를 실행하도록 검증 경로를 구분했습니다.
일반 macOS Terminal에서 실행하자 PyTorch의 MPS 사용 가능 상태가 참으로 바뀌었고, 실제 텐서의 장치는 mps:0으로 기록됐습니다. MLX도 GPU 행렬 곱을 수행했지만, 다음 정밀도 검사에서 예상과 다른 결과가 나왔습니다.
MLX가 기록한 장치 이름은 Apple M5 Pro, 실행 스트림은 Device(gpu, 0)이었습니다. PyTorch에서는 PYTORCH_ENABLE_MPS_FALLBACK을 해제한 상태로 실행해 자동 CPU 대체 없이 선택한 MPS 경로를 확인했습니다.
3. 정답을 아는 계산과 정밀도
첫 계산에는 숫자를 행과 열로 배치한 표, 즉 행렬을 사용했습니다. 두 행렬 [[1, 2], [3, 4]]와 [[5, 6], [7, 8]]를 곱했습니다. 첫 행의 값은 1×5 + 2×7 = 19, 1×6 + 2×8 = 22이고, 둘째 행은 43과 50입니다. 따라서 예상 결과 [[19, 22], [43, 50]]와 일치하는지 먼저 확인합니다.
작은 정수 계산이 맞는 것을 확인한 뒤에는 소수도 포함한 128행·128열의 행렬 두 개를 곱했습니다. 매번 같은 입력을 만들도록 난수 생성의 시작 조건인 시드를 20261005로 고정했습니다. 입력은 -1부터 1 사이의 값으로 만들었습니다.
소수 계산은 CPU와 GPU의 결과가 끝자리까지 항상 같지는 않습니다. 컴퓨터가 소수를 유한한 자릿수로 표현하고, 계산 순서에 따라 반올림 차이도 생기기 때문입니다. 그래서 결과가 완전히 같은지만 검사하기보다 “어느 정도 차이까지 허용할 것인가”를 먼저 정했습니다.
float16·float32·float64의 숫자는 값을 표현하는 데 사용하는 비트 수입니다. 일반적으로 더 많은 비트를 쓰면 숫자를 더 정밀하게 표현할 수 있습니다. 이번에는 같은 입력을 CPU에서 float64로 계산한 값을 비교 기준으로 두고, GPU의 float32와 float16 결과가 그 기준에서 얼마나 벗어나는지 확인했습니다. 이 CPU 결과도 수학적으로 오차가 전혀 없는 정답은 아닙니다.
허용 오차는 두 부분을 더해 계산했습니다. 절대 허용 오차(atol)는 결과값의 크기와 무관하게 허용하는 차이이고, 상대 허용 오차(rtol)는 기준값의 크기에 비례해 더하는 여유입니다. 기준값이 0에 가까울 때는 비율만으로 판단하기 어려워 절대 기준도 함께 사용합니다. NumPy의 비교 방식에 따라 각 원소를 절대 오차 ≤ atol + rtol × |기준값|으로 검사했습니다.
| 계산 형식 | 절대 허용 오차 | 상대 허용 오차 |
|---|---|---|
| float32 | 0.0001 (1e-4) | 0.0001, 즉 기준값 크기의 0.01% |
| float16 | 0.02 (2e-2) | 0.005 (5e-3), 즉 기준값 크기의 0.5% |
예를 들어 float32에서 기준값이 1이라면, 허용하는 차이는 0.0001 + 0.0001 × 1 = 0.0002입니다. 이는 판정식을 설명하는 예이며 별도의 실험 결과는 아닙니다.
이 숫자들은 실행 전에 검증 코드에 정한 이번 작은 계산의 점검 기준입니다. 작은 반올림 차이를 허용하면서 결과가 크게 달라지는지를 살펴보려는 것이며, float16은 숫자를 표현하는 정밀도가 낮아 더 넓은 범위를 두었습니다. 다만 정밀한 오차 분석으로 산출한 한계나 공식 권장값은 아닙니다. 실제 연구에서는 계산 규모와 결과의 용도에 맞춰 허용 오차를 다시 정해야 합니다. 여기서의 “통과”도 이 입력과 기준을 만족했다는 뜻으로 읽어야 합니다.
PyTorch에서 2×2 행렬 곱은 예상값과 일치했습니다. 128×128 float32의 최대 절대 오차는 약 0.000004995 (4.995e-6), float16은 0.005662였고 두 조건 모두 통과했습니다. MPS 장치에서 float64 텐서를 만드는 별도 확인은 지원하지 않는다는 오류를 반환했습니다. 따라서 CPU 기준 계산의 float64를 그대로 GPU 데이터형으로 사용할 수는 없었습니다.
MLX의 첫 float32 실행에서는 16,384개 원소 중 15,234개가 정해둔 허용 오차를 넘었습니다. 위반 원소의 최대 절대 차이는 약 0.009775였습니다. MLX 정밀도 문서는 일부 하드웨어에서 기본 행렬 곱이 내부적으로 낮은 정밀도를 사용할 수 있으며 입력과 출력의 데이터형은 float32로 유지된다고 설명합니다. 허용 오차를 바꾸지 않고, 먼저 기본 설정을 다시 실행해 실패 여부를 JSON에 기록한 뒤 MLX_ENABLE_TF32=0을 지정한 새 프로세스와 비교했습니다.
uv run --locked --offline python verify_ai.py mlx --observe-precision
MLX_ENABLE_TF32=0 uv run --locked --offline python verify_ai.py mlx
이 설정은 해당 실행에서 float32 행렬 곱의 내부 정밀도를 낮추는 경로를 사용하지 않도록 요청합니다. 새로 실행한 프로그램에서 최대 절대 오차는 약 0.000003262 (3.262e-6)로 줄었고 원래 허용 오차를 통과했습니다. 최대 절대 오차는 결과의 모든 칸을 비교했을 때 가장 크게 차이 난 칸의 값입니다. MLX CPU 스트림과의 최대 절대 차이도 약 0.000005722 (5.722e-6)로 허용 범위 안에 들어왔습니다. 기본 설정의 실패와 전체 float32 설정의 통과를 함께 기록했습니다. 이번 관찰은 공식 문서가 설명한 정밀도 정책과 일치하지만, 특정 내부 GPU 커널을 추적한 결과는 아닙니다.
| 실행 조건 | float32 최대 절대 오차 | float32 검사 | float16 최대 절대 오차 | float16 검사 |
|---|---|---|---|---|
| PyTorch MPS | 0.000004995 | 통과 | 0.005662 | 통과 |
| MLX 기본 설정 | 0.009775 | 실패 | 0.005662 | 통과 |
MLX MLX_ENABLE_TF32=0 | 0.000003262 | 통과 | 0.005662 | 통과 |
float16에는 float32보다 넓은 허용 오차를 적용했습니다. 두 데이터형의 통과 표시가 같은 정밀도를 뜻하지는 않습니다.
MLX에서도 같은 입력을 CPU와 GPU 양쪽에서 실행해 비교했습니다. MLX는 코드를 적은 순간 모든 계산을 끝내기보다 필요한 시점에 실행하는 지연 평가 방식을 사용합니다. 따라서 mx.eval()로 실제 계산을 완료한 뒤 결과를 읽었습니다.
4. 작은 학습으로 자동미분 확인
계산만 되는지에서 한 걸음 더 나아가, 답에 가까워지도록 숫자를 조정하는 작은 학습도 시험했습니다. 정답은 y = 2x + 1로 만들고, 학습할 식은 y = ax + b로 두었습니다. 찾아야 할 값은 입력에 곱하는 기울기 a = 2와 마지막에 더하는 절편 b = 1입니다.
-1부터 1까지 128개 입력을 만들고, 처음에는 a와 b를 모두 0으로 두었습니다. 예측과 정답의 차이를 각각 제곱해 평균낸 값이 평균제곱오차이며, 이번 학습에서는 이를 손실이라고 부릅니다. 손실이 줄면 예측이 정답에 가까워졌다는 뜻입니다.
자동미분은 손실을 줄이려면 a와 b를 어느 방향으로 바꿔야 하는지 계산하는 기능입니다. 그 계산을 이용해 값을 조금씩 바꾸는 갱신을 100번 수행했습니다. 학습률 0.1은 한 번에 조정할 크기를 정하는 시험 조건으로 두었으며, 최적의 학습률을 찾는 실험은 하지 않았습니다. 학습이 동작한다면 손실이 감소하고 a는 2, b는 1에 가까워져야 합니다.
PyTorch의 backward()와 MLX의 value_and_grad()는 손실을 줄일 방향을 계산하는 데 사용했습니다. 여기서 계산하는 손실의 기울기와, 식 ax + b의 매개변수인 기울기 a는 역할이 다릅니다. 각 프레임워크에서 GPU와 CPU 학습을 따로 실행하고 초기·최종 손실, 처음 계산한 기울기, 최종 매개변수를 결과 JSON에 기록합니다.
PyTorch GPU 학습은 초기 손실 약 2.35433에서 최종 약 0.0000011 (1.09986e-6)로 감소했습니다. 최종 기울기는 1.9981977, 절편은 0.9999999였고 CPU 학습의 최종 매개변수와 일치했습니다. MLX의 기본 설정과 MLX_ENABLE_TF32=0 실행에서도 동일한 최종 손실과 매개변수를 얻었고 각 CPU 학습과 일치했습니다. 기본 설정에서 행렬 곱 정밀도 검사가 실패했어도, 이 작은 학습이 실패한 것은 아니었습니다.
| 항목 | PyTorch MPS | MLX GPU 기본 / 전체 float32 |
|---|---|---|
| 초기 손실 | 약 2.35433 | 약 2.35433 |
| 100회 갱신 후 손실 | 1.09986e-6 | 1.09986e-6 |
| 기울기 / 절편 | 1.9981977 / 0.9999999 | 1.9981977 / 0.9999999 |
| CPU 학습과 최종 매개변수 대조 | 일치 | 일치 |
이 결과는 알려진 작은 선형 문제에서 자동미분과 갱신이 동작했음을 보여줍니다.
5. 기록 범위와 다음 단계
검증 스크립트는 환경 경로·버전·장치 상태·수치 결과와 프레임워크 메모리 카운터를 기록합니다. PyTorch 할당/드라이버 메모리와 MLX 활성/캐시/피크 메모리는 측정 범위가 달라, 숫자만 놓고 어느 프레임워크가 메모리를 덜 쓴다고 비교하지 않습니다. 이번 입력은 작으므로 대형 모델이 들어가는 용량이나 지속적인 부하의 안정성을 평가하는 자료도 아닙니다.
활성·현재 할당 메모리는 살아 있는 계산 데이터에 쓰이는 공간이고, 캐시는 다음 계산에 재사용하려고 보관한 공간입니다. 피크는 측정 구간에서 관찰한 최대 사용량입니다. PyTorch의 드라이버 할당 값은 텐서 데이터뿐 아니라 Metal 실행에 필요한 다른 할당도 포함합니다.
검증 마지막 시점에 저장된 메모리 값은 다음과 같습니다. 단위는 바이트이며, MLX 피크는 해당 프로세스에서 카운터를 초기화한 뒤의 관찰값입니다.
| 실행 조건 | 메모리 기록 |
|---|---|
| PyTorch MPS | 현재 할당 33,024 / 드라이버 할당 9,125,888 |
| MLX 기본 설정 | 활성 131,088 / 캐시 265,348 / 피크 294,928 |
| MLX 전체 float32 | 활성 131,088 / 캐시 396,424 / 피크 393,232 |
프레임워크가 반환한 권장 작업 메모리는 19,069,665,280바이트였지만, 이는 이번 실험에서 확보하거나 사용한 메모리량이 아닙니다. 입력 크기를 늘려 한계를 찾는 시험과 처리 속도 측정은 수행하지 않았습니다. 정확도 조건을 바꿀 때의 속도 비용도 이번 기록에서는 판단하지 않습니다.
연구 코드 편집을 위해 VS Code에 Codex 확장도 추가했습니다. 이번 글에서는 설치 사실만 기록하며, 확장을 이용한 연구 작업 흐름은 실제 사용 경험이 쌓인 뒤 정리할 생각입니다.
이번 단계에서는 GPU 접근, 작은 계산과 학습, 정밀도 설정의 영향을 확인했습니다. MLX의 float32 기준 검증에는 프로세스 실행 시 MLX_ENABLE_TF32=0을 명시하기로 했습니다. 시스템 전체 환경변수로 추가하지 않고 실험 실행 조건으로 남겼습니다.
다음에는 이번 실행 환경을 기반으로 로컬 LLM 런타임과 작은 문서 집합을 다룹니다. 모델과 양자화·문맥 조건, 실제 응답과 근거 확인을 별도 실험으로 기록하겠습니다.