
GoMLX를 이용한 Go 단독 손글씨 숫자 분류 ― 학습부터 추론까지 Python을 사용하지 않는 구현
요약
GoMLX 프레임워크를 사용하여 Python 없이 Go 언어만으로 머신러닝 모델의 데이터 로딩, 학습, 추론 전 과정을 구현한 사례 연구입니다. 8x8 손글씨 숫자 분류를 위한 MLP 모델을 구축하여 높은 정확도와 저지연, 저메모리 성능을 입증했습니다.
핵심 포인트
- GoMLX를 활용해 Python 의존성 없는 순수 Go 기반 ML 워크플로우 구현
- MLP 모델로 scikit-learn 참조 구현과 유사한 0.95 수준의 정확도 달성
- 단일 바이너리 배포 및 cgo 없는 크로스 컴파일 지원으로 운영 편의성 증대
- 매우 낮은 메모리 사용량(약 32MB)과 빠른 추론 속도(샘플당 20µs) 확인
머신러닝 (Machine Learning)은 모델의 학습과 추론 모두 Python이 사실상의 표준이다. 하지만 단일 바이너리 배포, 저메모리, 저지연 (Low Latency)과 같은 운영상의 이유로 이를 다른 언어로 완결 짓고 싶은 상황이 상정된다. 본고에서는 손글씨 숫자 (8×8 픽셀) 분류를 위한 다층 퍼셉트론 (MLP)에 대해, 데이터 로딩 · 학습 · 추론의 모든 과정을 Go 단독으로 구현 및 평가한 사례 연구를 보고한다.
구현에는 Go 제작 머신러닝 프레임워크인 「GoMLX[1]」를 사용하였으며, 해당 프레임워크의 자동 미분 (Automatic Differentiation), Adam 최적화기 (Optimizer), 학습 루프를 통해 MLP (64→32→10, ReLU 활성화 함수)를 학습하였다.
데이터셋은 scikit-learn의 load_digits의 원전인 UCI optdigits를 사용하였다. 순수 Go 백엔드 (Pure Go backend)에서는 데이터셋을 바이너리에 임베딩함으로써 실행 시 외부 파일이나 네트워크가 필요 없는 구성으로 하였다 (XLA 백엔드는 최초 실행 시 PJRT 플러그인 취득을 동반한다).
테스트 정확도는 8개 시드 평균으로 0.9526 ± 0.0031 (기본 실행의 한 예는 0.9527)이었다. 동일 학습 예산 (40 에포크 (Epoch))의 선형 베이스라인 (0.9325 ± 0.0017)을 상회하지만, 두 모델을 수렴할 때까지 학습시킨 비교에서는 선형 0.9510 ± 0.0010 대 MLP 0.9608 ± 0.0022로 나타났으며, 동일 예산에서의 차이 대부분은 선형 모델의 수렴 속도 저하에서 기인한다 (은닉층 (Hidden Layer)의 기여도는 수렴 후 약 0.01. 3.7). XLA 백엔드의 8개 시드 평균은 0.9511 ± 0.0034로 순수 Go와 비슷한 수준이었다 (기본 실행의 한 예는 0.9560이지만, 이는 분포의 상단에 위치한 단일 시행이며 대표값은 아니다).
2종류의 라벨 대조 실험에서는 테스트 정확도가 올바른 대응 관계의 잔존량에 충실하게 추종한다는 것 (전체 대응 있음 0.95 · 약 1할 잔존 0.16 · 잔존 없음 0.00)을 확인하였으며, 보고된 정확도가 학습 신호에 기인함을 지지하였다. 검증 집합 (Validation Set)을 분리한 재시험 (0.9500 ± 0.0027)은 테스트 정확도에서의 에포크 모니터링에 의한 낙관적 편향 (Optimistic Bias)이 주요 결론을 바꾸지 않는 범위 내에 있음을 지지하였다.
순수 Go 백엔드에서는 학습 40 에포크와 전체 추론을 합쳐 평균 약 2.6초 · 피크 메모리 약 32MB (10회 시행)로 완료되었으며, 추론은 단일 샘플당 약 20µs였다. 데이터를 임베딩한 약 10MB의 단일 바이너리를 cgo 없이 주요 OS/아키텍처로 크로스 컴파일할 수 있었고, 별도의 libc 환경 (Alpine) 컨테이너 위에서도 동일한 시드로 동일한 결과를 얻을 수 있었다. 학습 결과는 scikit-learn의 참조 구현 (Reference Implementation)과도 비슷한 수준 (둘 다 평균 0.9526)이었으며, 학습된 모델은 체크포인트로서 저장 및 재로드할 수 있었다.
본고는 데이터 준비부터 학습 · 추론 · 배포까지를 Python을 거치지 않고 Go 단독으로 완결할 수 있음을, 본 태스크 (8×8 숫자 분류, 소규모 MLP)에서 정량적으로 보여주는 것이다. 본고의 핵심 명제는 "2026년 시점의 GoMLX에서 본 태스크의 학습을 포함한 모든 공정이 cgo 없는 순수 Go로 성립하며, 테스트 정확도는 scikit-learn 참조 구현과 비슷한 수준이고, XLA 구성이 수반되는 런타임 상주분 (본 환경에서 약 160MB)의 고정 메모리 비용을 갖지 않는다"는 것이며, 공개된 결과물을 통해 재시험 및 반증이 가능하다. 성능상의 우위는 본 태스크 규모에서의 관측 결과이며, 실용 규모로의 일반화는 향후 과제이다. 또한 본고는 새로운 알고리즘의 제안이 아니라, 기존 프레임워크를 이용한 실증 연구 (사례 연구)이다.
키워드: Go, GoMLX, 머신러닝 (Machine Learning), 신경망 학습 (Neural Network Training), 자동 미분 (Automatic Differentiation), 손글씨 숫자 인식
머신러닝 에코시스템은 Python을 중심으로 구축되어 있으며, 학습과 추론 모두 Python으로 완결하는 것이 일반적이다. 한편으로, 프로덕션 환경의 서비스를 Go와 같은 언어로 구축하고자 하는 수요는 일정 수준 존재한다. ONNX Runtime의 Go 바인딩이나 go-tflite 등의 도구군, 그리고 GoMLX 자체의 존재가 그 방증이라 할 수 있다. 주요 동기는 다음과 같다.
- 추론 · 학습을 포함한 서비스를 단일 바이너리로 배포하고 싶다 - Python 런타임 · 대규모 의존성 · 비대해진 컨테이너를 피하고 싶다
- 병렬 처리 · 저메모리 · 저지연과 같은 운영 특성을 단일 프로세스에서 얻고 싶다
물론 Python에서도 PyInstaller 등을 사용하여 단일 실행 파일로 만드는 것이 가능하다. Go의 우위는 그 가능 여부에 있는 것이 아니라, 정적 단일 바이너리 (Static Single Binary)의 크기, 기동의 가벼움, cgo 없는 크로스 컴파일 (Cross-compilation), 의존성 해결의 단순함에 있다 (3.13). 다만 본고에서 실측하는 것은 Go 측면(3.12, 3.13)뿐이며, PyInstaller 결과물과의 정량적 비교는 수행하지 않았다 (일반적인 성질로서의 언급에 그친다).
이러한 요구사항은 추론을 운영할 때의 전형적인 구성과 관련이 있다. 요청마다 저지연 (Low latency)으로 응답하려면 모델을 서비스 프로세스 내에 상주시키고, 다수의 요청을 병렬로 처리하고 싶다. Python의 수치 커널은 내부적으로 GIL (Global Interpreter Lock)을 해제하기 때문에 GIL이 항상 추론의 병목 (Bottleneck)이 되는 것은 아니다. 하지만 Python 런타임 (Runtime)을 기존 Go 서비스에 공존시키기 어렵다는 점이, 추론을 별도 프로세스 또는 별도 서비스 (예: gRPC를 통한 추론 서버)로 분리하게 만드는 한 원인이 될 수 있다.
프로세스 분할은 스케일링 (Scaling)이나 장애 격리 (Fault isolation)와 같은 적극적인 이유로 선택되는 설계 판단이며 Python 고유의 제약은 아니지만, 분할을 채택할 경우 프로세스 간 통신 (IPC)의 레이턴시와 운영 대상의 증가라는 비용을 수반한다. 학습과 추론을 Go의 단일 바이너리에 공존시킬 수 있다면, 이러한 언어 경계와 서비스 분할을 제거할 수 있는 가능성이 있다. 다만 본고는 이러한 운영상의 효과 그 자체를 8×8 태스크로 측정한 것이 아니라, 이는 동기일 뿐 검증된 결과는 아니다.
본고가 실측으로 보여주는 것은 자원, 배포, 병렬성 (3.4, 3.11, 3.13)에 관한 특성이다.
본고의 목적은 학습을 포함한 전 공정을 Go 단독으로 구현하여, Python을 사용하지 않고 손글씨 숫자 분류 모델을 학습 및 평가할 수 있음을 본 태스크 (8×8 숫자 분류, 소규모 MLP)를 통해 구체적으로 보여주는 것이다. 이를 통해 데이터 준비부터 학습, 추론, 배포까지를 단일 언어 및 단일 바이너리로 완결시키는 구성의 현실성을 하나의 사례로서 제시한다.
사례로 「UCI optdigits」를 선택한 이유는 다음 세 가지 점 때문이다.
(i) UCI 규정의 train/test 분할을 가진 표준 벤치마크로서 분할의 자의성을 배제할 수 있다는 점
(ii) scikit-learn (load_digits) 과 계열이 같은 데이터이므로 참조 구현과의 대조 (3.16)가 가능하다는 점
(iii) 바이너리에 임베딩하기 적합한 소규모 (텍스트 기준 약 800KB)이며, Go 단독 완결의 검증 과제로 다루기 쉽다는 점
본고는 새로운 알고리즘을 제안하는 것이 아니라, 기존 프레임워크 (GoMLX)를 이용한 사례 연구이다. 본고가 새롭게 보여주는 것은 다음 두 가지이다. 첫째, 데이터 준비부터 학습, 추론, 배포까지의 ML 전 공정이 실제로 Go만으로 완결된다는 것. 둘째, 그 과정에서 선택하게 될 두 가지 백엔드 (순수 Go / XLA)의 속도, 메모리, 배포성의 트레이드오프 (Trade-off) 실측값이다.
GoMLX [1]은 Google의 XLA/PJRT [8] (XLA는 Accelerated Linear Algebra, PJRT는 그 실행을 담당하는 런타임 API이며, 둘 다 TensorFlow 및 JAX의 계산 기반임)를 백엔드로 이용할 수 있는 Go 제작 머신러닝 프레임워크로, 추론뿐만 아니라 자동 미분 (Automatic Differentiation), 최적화기 (Optimizer), 학습 루프 (Learning Loop) (ml/train) 를 갖추고 있다. 또한 순수 Go 백엔드를 가지고 있어, XLA를 도입하지 않고도 학습과 추론을 Go 단독으로 실행할 수 있다.
Go에서 ML 추론을 수행하는 기존 수단으로는 TensorFlow의 Go 바인딩 [2] (현재는 TensorFlow 팀의 공식 지원이 종료되어 커뮤니티가 유지 중), TensorFlow Lite를 위한 go-tflite [3], ONNX 모델을 Gorgonia 백엔드로 실행하는 onnx-go [4], 순수 Go 신경망 라이브러리인 Gorgonia [5] 등이 있다.
이들은 cgo 의존성 (빌드 및 배포의 제약)이나 대응 연산자의 한계, 혹은 추론 전용이라 학습을 전제하지 않는 점 (go-tflite [3], onnx-go [4]) 등의 제약을 가진다. Gorgonia [5]는 자동 미분을 통한 학습을 지원하는 순수 Go 구현이지만, XLA 등의 액셀러레이터 (Accelerator) 백엔드를 전제로 하지 않으며, 대응 연산자나 구현의 성숙도에 제약이 있다.
GoMLX[1]는 XLA/PJRT와 순수 Go(Pure Go) 양쪽을 백엔드(Backend)로 선택할 수 있으며, 자동 미분(Automatic Differentiation)과 학습 루프(Learning Loop)를 갖추고 있다는 점에서 이들과 달리 "학습부터 추론까지를 Go로 완결"할 수 있다. 본고는 그 학습 측면을 포함한 실증에 초점을 맞춘다. 또한, Rust(burn/candle 등)나 C++/C#(NativeAOT)도 정적 바이너리(Static Binary) 및 저용량(Low Footprint)의 이점을 가질 수 있으나, 본고는 언어 간의 우열 비교가 아니라 이미 Go를 채택하고 있는 환경에서의 ML 공정 성립성에 초점을 맞춘다(언어 간 비교는 향후 과제이다).
본 구현은 학습 및 추론의 어느 단계에서도 Python을 사용하지 않으며, Go 바이너리 단독으로 완결된다. 데이터셋도 go:embed로 임베딩(Embedding)하기 때문에, 실행 시 파일이나 네트워크를 필요로 하지 않는다. 처리 흐름을 [IMG:1]에 나타낸다.
그림 1. 처리 파이프라인 (모두 Go, 파이프라인 도표)
optdigits를 go:embed로 임베딩
│
▼
...
본 구현의 리포지토리(Repository)는 공개되어 있다 (URL: 추후 기재 예정). 리포지토리 구성은 다음과 같다 (이 외에도 README, NOTICE, [IMG:2] 이미지 파일, 실험 하네스(docs/experiments/) 등의 주변 파일을 포함한다).
gomlx-digit-inference/
├── go.mod
├── main.go # 학습 루프 + 평가 + 분류
...
또한, 위 리포지토리의 main.go / data.go는 본고의 핵심인 데이터 로딩, 학습, 추론, 대조 실험(-shuffle)을 기본 구성(은닉층 32, lr=0.001, 40 에포크)으로 실행한다.
3.5 이후에서 보고할 비교 및 분석(대조의 완전 교체, 다중 시드(Multi-seed), 베이스라인(Baseline), 은닉층 너비 및 학습률의 민감도, 혼동 행렬(Confusion Matrix), 학습 곡선([IMG:2]), 추론 레이턴시(Latency)/처리량(Throughput)·병렬성, 체크포인트(Checkpoint) 저장 및 재로드, scikit-learn과의 비교, 검증 분리)은 이 핵심 코드에 상수 변경 및 소규모 수정을 가한 파생 빌드·실험 하네스, 그리고 보조 스크립트·외부 측정(/usr/bin/time 등)을 통해 얻은 값이다.
이 중 정확도 계열의 수치는 가변 은닉층(0=선형)·다중 시드·2종의 대조·검증 분리를 단일 커맨드라인 플래그(Command-line Flag)로 전환할 수 있는 실험 하네스로 재구현하여 재측정하였으며, 본문의 값과 일치함을 확인하였다 (다중 시드 통계 3.6은 평균·SD·최소·최대까지 일치한다. 표 5의 40 에포크 값도 일치한다). 이 하네스와 결과 로그, 그리고 [IMG:2]의 원본 데이터(에포크별 정확도 수치열) 및 그리기 스크립트는 리포지토리의 docs/experiments/에 동봉되어 있다. 반면, 시간·처리량 계열의 측정 스크립트는 현재 동봉되지 않았으며, 각 표의 값을 생성하는 절차와 함께 리포지토리에 추가하여 재현 가능성을 확보할 예정이다 (4.2 참조).
- Go 1.26.4 (darwin/arm64, Apple Silicon)
- GoMLX
v0.27.4-0.20260601103647-1601d229cd6e, computev0.0.0-20260601092454-b91e8854304a - 의존성은 모두 Go 모듈 (cgo를 동반하지 않는 순수 Go 구성)
측정의 절대값은 Apple Silicon의 가변 클록(Clock), 열 상태(Thermal State), 병렬 부하에 의존한다. 본고의 시간·메모리 값은 전원 연결 및 전경 프로세스(Foreground Process)가 최소인 상태에서 연속 실행하여 얻은 것이지만, 제3자 환경에서의 절대값 일치는 보장하지 않는다 (상대적 비교를 주안점으로 한다). 각 측정의 시도 횟수·통계량·웜업(Warm-up) 조건 목록은 부록 A에 정리한다.
scikit-learn[6]의 load_digits의 원천인 UCI optdigits[7]를 사용하였다. 각 표본은 8×8=64 픽셀(각 픽셀 016의 정수 계조)과 정답 레이블(09)로 구성된다. 참고로 load_digits가 반환하는 것은 본 데이터셋의 테스트 집합(1797건)에 해당하는 발췌본이며, 본고에서는 UCI 규정의 전체 분할, 즉 학습 3823건·테스트 1797건을 사용하였다.
Go 측에서 CSV를 해석하고, 최적화의 안정화를 위해 **픽셀 값을 16으로 나누어 [0,1]로 정규화(Normalization)**하였다. 또한, 64 픽셀 벡터의 완전 일치를 통한 대조를 통해, 학습·테스트 집합 간에 중복 표본이 존재하지 않음(0건) 및 학습 집합 내에도 동일 픽셀의 중복이 없음을 확인하였다. 따라서 테스트 정확도가 표본 누출(Sample Leakage)에서 기인할 가능성은 배제된다.
본고는 UCI 규정의 train/test 분할을 그대로 사용하며, 검증 집합(validation)을 분리하지 않았다. 이러한 설계상의 판단과 그 함의(낙관적 편향 (Optimistic Bias))에 대해서는 3.17에서 재확인하고, 4.2에서 논한다.
입력 64차원, 은닉층(Hidden Layer) 32유닛(ReLU 활성화), 출력 10클래스의 MLP(Multi-Layer Perceptron)로 구성하였다.
순전파(Forward Pass) 계산은 학습과 추론에서 공유하며, 변수를 일정 범위(scope)인 scope.In("mlp") 아래에 확보함으로써 학습된 가중치를 추론 시에 그대로 재사용할 수 있도록 하였다.
func forward(scope *model.Scope, x *Node) *Node {
g := x.Graph()
s := scope.In("mlp")
...
}
여기서는 구성 요소(행렬 곱, 편향(Bias) 가산, ReLU)를 명시하기 위해 저수준 API(Low-level API)로 forward를 직접 기술하였다. 또한, 편향 가산 전의 ExpandLeftToRank는 형상(Shape)을 맞추기 위한 작업이다. NumPy나 PyTorch에서는 암묵적인 브로드캐스팅(Broadcasting)으로 처리되는 과정이지만, GoMLX의 저수준 API는 형상을 명시적으로 맞추는 설계이기 때문에 이러한 종류의 조작이 겉으로 드러난다. GoMLX에는 ml/layers 하위에 Dense 등의 고수준 레이어(High-level Layer)도 있으며, 이를 사용하면 형상 맞춤이 내부에서 처리되어 실무에서는 간결하다. 참고로, 위 코드는 기본 구성(은닉층 32)을 보여주기 위해 층 너비(Layer Width)를 하드코딩하였다. 3.7 및 3.8의 비교 실험에서는 은닉층 너비를 인자로 받아(0은 은닉층이 없는 선형 모델) 대응하는 형상의 변수를 생성하는 가변 버전을 사용하였다.
손실 함수(Loss Function)는 다중 클래스 분류를 위해 loss.SparseCategoricalCrossEntropyLogits(레이블은 정수, shape [batch,1])를 사용하였고, 최적화 도구(Optimizer)는 Adam(학습률 0.001)을 사용하였다.
학습기 train.NewTrainer를 구성하였으며, Dataset 추상화를 사용하지 않고 미니 배치(Mini-batch)마다 TrainStep을 직접 호출하는 수동 루프 방식을 택했다(데이터가 3,823건으로 적어 전체를 메모리에 유지할 수 있으므로, 추상화에 의한 은닉보다는 투명성을 우선시했다. 실무 규모에서는 prefetch 등을 갖춘 Dataset 추상화가 바람직하다). 배치 크기(Batch Size)는 64, 에포크(Epoch) 수는 40으로 설정하였으며, 각 에포크마다 학습 표본의 순서를 셔플(Shuffle)하였다. 기본 하이퍼파라미터(은닉층 32, 학습률 0.001)는 Adam의 표준적인 기본 학습률과 관행적인 2의 거듭제곱 층 너비를 구현 초기에 고정한 것이며, 테스트 정확도에 기반한 탐색을 거친 값이 아니다(리포지토리의 초기 커밋 시점부터 이 값이였으며, 민감도 분석(3.8)은 이 기본값을 고정한 후의 버전에 추가된 사후 분석이다). 따라서 기본값 선정 경로를 통한 테스트 집합으로의 계통적 편향(Systematic Bias)은 없다.
난수 시드(Random Seed)는 변수 초기화(ParamInitialSeed = 42)와 에포크별 셔플을 통해 별도의 계통으로 고정하였다. 3.6 이후의 다중 시드 실험은 이러한 시드들을 변경하여 재빌드 및 재실행하여 얻은 결과이다.
backend := compute.MustNew()
store := model.NewStore()
store.SetParam(model.ParamInitialSeed, int64(42))
...
주요 API를 표 1에 나타낸다. 코드 예제에서는 가독성을 위해 초기화 계열에 Must*(에러 발생 시 panic)를 사용하였으나, 대응하는 비(非) Must 버전(error를 반환)도 존재하므로 실제 운영 환경에서는 에러 복구(Error Recovery)를 구현할 수 있다. 학습 단계는 (…, error)를 반환하는 비(非) Must API이며, 위 코드와 같이 본 구현에서도 err를 검사하고 있다.
표 1. 학습 및 추론에 사용된 GoMLX의 주요 API
| 목적 | API |
|---|---|
| 백엔드 생성 | compute.MustNew() (순수 Go는 _ "github.com/gomlx/compute/gobackend" 임포트로 등록) |
| 변수 | scope.VariableWithShape(name, shapes.Make(dt, dims...)) |
| 활성화 함수 | activation.Relu(x) (ml/layers/activation) |
| 손실 (다중 클래스) | loss.SparseCategoricalCrossEntropyLogits |
| 옵티마이저 (Optimizer) | optimizer.Adam().LearningRate(0.001).Done() |
| 학습기 (Trainer) | train.NewTrainer(backend, store, modelFn, lossFn, opt, nil, nil) |
| ... | model.Exec (컴파일된 실행기를 생성 및 재사용. 3.11) |
| 결과 추출 | tensors.MustCopyFlatData[float32](logits) |
Node 및 Graph는 github.com/gomlx/gomlx/core/graph의 도트 임포트(dot import)로 이용한다.
학습 후, 학습된 변수를 유지하는 store를 그대로 사용하고, model.MustCallOnce로 동일한 forward를 실행하여 추론하였다. 출력된 logits를 행별로 argmax 하여 테스트 전체에 대한 정확도를 산출하였다. 또한, MustCallOnce는 호출 시마다 그래프 구축 및 컴파일을 동반하는 단발성 실행용 API이며, 본 절의 정확도 평가(테스트 전체를 1개 배치로 1회 추론)에는 이것으로 충분하다.
한편, 3.11의 추론 레이턴시(Latency) 및 처리량(Throughput) 측정에서는 컴파일된 실행기(model.Exec)를 생성하여 재사용하였으므로, 반복 추론의 실행 경로는 본 절과 다르다(표 1). forward가 학습 시와 동일한 스코프(Scope) 및 변수명을 사용하기 때문에, 추가적인 가중치 전달 없이 학습된 값이 재사용된다. 학습 진행 모니터링(3.1)과 하이퍼파라미터 민감도(3.8) 역시 테스트 정확도로 관찰하였으므로, 보고하는 테스트 정확도에는 테스트 집합에 대한 낙관적 편향(Optimistic bias)이 포함될 수 있다. 이 편향의 크기는 검증 집합을 분리한 재시험(3.17)을 통해 추정하였다(4.2도 참조).
기본값은 순수 Go 백엔드이다.
환경 변수 GOMLX_BACKEND=xla:cpu를 설정하고 _ "github.com/gomlx/gomlx/backends/default"를 임포트함으로써 XLA 백엔드로 전환할 수 있다.
얻어진 높은 정확도가 구현상의 인위적인 산물이 아니라 진정한 학습 신호에 의한 것임을 확인하기 위해, 두 종류의 대조 실험을 마련하였다. 두 실험 모두 테스트 집합은 변경하지 않고, 학습 라벨만을 조작하여 동일한 조건에서 학습한다.
첫 번째 대조 (라벨 치환, -shuffle):
학습 표본의 라벨을 무작위로 치환한다 (라벨 분포는 유지한 채, 이미지와 라벨의 대응 관계를 파괴한다). 단, 이 치환이 대응 관계를 완전히 파괴하지는 않는다는 점에 주의가 필요하다. 치환 후의 라벨이 우연히 원래의 올바른 라벨과 일치할 확률은 클래스 분포의 제곱합으로 주어지며, 거의 균등한 10개 클래스의 경우 약 0.1, 즉 기대값으로 약 1할의 표본에는 올바른 대응(약한 학습 신호)이 잔존한다. 따라서 올바르게 구현되었더라도 테스트 정확도는 찬스 레벨(Chance level, 1/10 = 0.10)에 딱 맞지 않고, 그보다 약간 높은 수준에 도달할 수 있다. 프로그램 내 판정은 테스트 정확도가 0.20 미만이면 대조 실험에 성공한 것(파괴된 대부분의 대응으로부터 학습하지 않음)으로 간주하였다. 이 0.20은 "찬스 + 잔존 신호"로 설명 가능한 범위(약 0.10~0.20)의 상단에 설정한 사후적·편의적 기준이며, 엄격한 통계적 임계값은 아니다.
두 번째 대조 (완전 치환, derangement):
치환 후 올바른 라벨을 유지하는 표본이 0건이 되도록 치환을 수정한다 (클래스 수준의 derangement. 라벨의 다중 집합은 보존되므로 분포는 변하지 않는다. 잔존 건수가 0건인지는 실행 시 검사한다). 이 조건에서는 학습 가능한 올바른 대응이 전혀 남지 않으므로, 잔존 신호의 혼란(Confounding) 없이 "대응을 파괴하면 일반화 정확도를 얻을 수 없다"는 것을 검증할 수 있다. 완전 치환은 파생 하네스(2.1)에서 구현 및 실행하였다.
학습이 성립하지 않는 본질적인 근거는, 어떤 대조 조건에서도 학습 정확도(치환 후 라벨 기준)가 약 0.17~0.20까지만 상승한다는 점에 있다(3.5·3.10). 또한, 이러한 대조는 이미지와 라벨의 대응에 의존하지 않는 종류의 인공물(artifact)이기에 평가 코드의 버그는 검출할 수 없다(train/test 간의 표본 중복에 대해서는 2.3의 대조를 통해 0건임을 확인했다)(4.2).
순수 Go 백엔드(pure-Go backend)에서의 학습 진행 과정을 아래에 나타낸다(발췌).
테스트 정확도는 에포크(epoch)와 함께 대체로 향상되어, 초기 0.5170에서 40 에포크에서 0.9527에 도달했다(전체 에포크의 추이는 그림 2를 참조).
Loaded 3823 training and 1797 test digits (8x8, 10 classes)
Backend: Go Backend — Go Portable Compute Backend (parallelism=20)
Training (pure-Go autodiff + Adam):
...
이때 표시되는 0.9527은 프로그램 기본 시드(default seed)에 의한 단일 시행의 한 예이다. 본고의 순수 Go 대표값은 재현 가능한 8개 시드의 평균인 0.9526 ± 0.0031로 한다(3.6).
제시를 위해, 올바르게 분류된 라벨이 서로 다른 5개 표본을 ASCII 드로잉과 함께 출력하였다.
이는 정성적인 제시 예시이며, 성능 지표가 아니다. 선정 기준에 "올바르게 분류됨"을 포함하고 있으므로, "5/5 정답"은 선정 과정에서 자명하게 성립하는 것이며 분류기의 정확성에 대한 증거가 될 수 없다. 정확성의 근거는 어디까지나 테스트 전체 건에 대한 정확도(3.6)이다.
Digit #1 predicted=0 expected=0 ✓
Digit #2 predicted=1 expected=1 ✓
Digit #3 predicted=3 expected=3 ✓
...
순수 Go와 XLA 어느 백엔드에서도 동일한 forward
· 데이터·하이퍼파라미터로 학습·추론이 성립하며, 동일한 수준의 테스트 정확도를 얻었다(표 2).
표 2. 백엔드별 결과 (기본 실행의 한 예)
| 백엔드 | 추가 의존성 | 최종 테스트 정확도 |
|---|---|---|
| 순수 Go (gobackend) | 불필요 | 0.9527 |
| XLA (xla:cpu) | PJRT 플러그인 (최초 자동 취득) | 0.9560 |
표 중의 정확도는 기본 시드에 의한 단일 시행의 한 예이다. 백엔드의 대표값은 다중 시드 평균(순수 Go 0.9526 ± 0.0031, XLA 0.9511 ± 0.0034. 3.6·3.16)을 사용한다. 특히 XLA의 0.9560은 8개 시드 분포의 상단에 위치하는 값이며, 단일 시행 간의 비교를 통해 백엔드의 우열을 판단해서는 안 된다(3.14).
실행 시 외부 파일이나 네트워크를 필요로 하지 않고 학습부터 추론까지 완결된 것은 순수 Go 백엔드이다. XLA 백엔드는 PJRT 공유 라이브러리(cgo)에 의존하며, 최초 실행 시 플러그인 취득을 동반한다. 두 백엔드의 정확도 차이에 대해서는 3.14에서, 다중 시드 분포에 대해서는 3.6·3.16에서 다룬다.
서론에서 언급한 저메모리·경량 배포라는 동기에 대응하기 위해, 프로세스 전체(40 에포크의 학습과 총 1797건의 추론 포함)의 실행 시간과 피크 메모리(peak memory)를 측정하였다.
각 백엔드에 대해 사전에 1회의 워밍업(warm-up)을 수행한 후, 동일 바이너리를 10회 연속 실행하여(n=10), /usr/bin/time -l
으로 실시간(wall-clock)과 최대 상주 메모리(peak RSS)를 취득하였다.
값은 평균 ± 표준 편차(괄호 안은 최소~최대)로 나타낸다(표 3).
모두 CPU 실행이며, GPU는 사용하지 않았다. 테스트 정확도는 난수 시드를 고정했으므로 모든 시행에서 일정하다.
표 3. 백엔드별 계산 자원 (n=10, Apple Silicon, CPU 전용)
| 백엔드 | 실행 시간 (s, 평균±SD) | 피크 메모리 (MB, 평균±SD) | 최종 테스트 정확도 |
|---|---|---|---|
| 순수 Go (gobackend) | 2.55 ± 0.03 (2.51~2.60) | 31.5 ± 0.9 (30.7~33.2) | 0.9527 |
| XLA (xla:cpu) | 2.02 ± 0.05 (1.97~2.12) | 191.1 ± 3.1 (186.1~196.2) | 0.9560 |
10회의 시도(trial)를 통해 측정값의 편차는 작았으며, 실행 시간의 변동 계수는 모두 3% 미만이었다(±0.03은 동일 세션 내 연속 실행의 편차이며, 세션 간·환경 간의 변동은 포함하지 않음. 부록 A). 순수 Go(pure Go)의 평균값을 기준으로 할 때, XLA는 총 실행 시간이 약 21% 짧았으며((2.55−2.02)/2.55≒0.21. 속도 향상률로 표현하면 2.55/2.02−1≒26%에 해당), 피크 메모리(peak memory)는 순수 Go의 약 6.1배이며 절대 차이로는 약 160MB였다(191.1/31.5≒6.1, 191.1−31.5≒160). 단, 이 실행 시간은 /usr/bin/time -l에 의한 프로세스 전체의 측정값으로, 기동(startup), PJRT 로드, JIT (Just-In-Time) 컴파일, GC를 포함한다.
따라서 "약 21% 짧다"는 것은 계산 커널(compute kernel) 단독의 속도 차이가 아니라, 본 태스크 및 본 측정 조건에서의 총 실행 시간을 비교한 것이다. 또한 본 절의 실행 시간은 실시간(wall-clock time)이며, CPU 시간(user+sys)은 보고하지 않았다. XLA는 내부적으로 멀티스레드(multi-threading) 실행을 적극적으로 사용하기 때문에, 총 CPU 시간(프로세스의 CPU 점유량) 측면에서는 순수 Go보다 클 가능성이 있으나, 본 논문은 대기 시간으로서의 실시간을 평가 대상으로 삼았다.
이상의 결과로, 본 태스크 규모이면서 배포 용이성과 메모리를 중시하는 용도에서는 순수 Go 백엔드가 저메모리, 작은 바이너리, 가벼운 기동 측면에서 요구사항을 충족한다(정상 스루풋(steady-state throughput) 및 실행 시간 측면에서 XLA가 더 빠른 점은 트레이드오프(trade-off)이다). 절대값(약 32MB, 약 2.6초)이 작은 것은 주로 모델 규모가 작기 때문이다. 또한 메모리 차이의 대부분은 PJRT 런타임(runtime)의 상주라는, 모델 규모에 의존하지 않는 고정 비용으로 보인다. 만약 그렇다면 모델을 키울수록 고정 비용의 상대적인 비중은 낮아지며, "6.1배"라는 비율은 필연적으로 축소될 것이다.
따라서 규모를 초월하여 적용할 수 있는 지식은 배율이 아니라, "XLA 구성은 런타임 상주분(본 환경에서 약 160MB)의 고정 메모리 비용을 수반한다"라는 절대 차이에 대한 통찰이며, 6.1배는 본 태스크 규모에서의 관측값이다. 메모리 제약이 엄격한 환경(에지(edge), 사이드카(sidecar) 등)에서는 이 고정 비용의 유무가 순수 Go의 이점으로 특히 작용할 수 있다.
라벨 치환 (-shuffle): 40 에포크(epoch)가 지나도 테스트 정확도는 0.1608에 머물렀으며, 일반적인 실험의 0.9527과 명확한 대조를 이루었다(표 4). 참고로 0.1608은 찬스(chance, 0.10)를 통계적으로 명확하게 상회한다(찬스와의 차이 0.0608은 찬스 하에서의 표준 오차 약 0.007의 8배 이상에 해당한다). 이는 학습의 실패를 의미하는 것이 아니라, 2.8에서 언급한 잔존 신호(residual signal) — 랜덤 치환 후에도 기대치상 약 1할의 표본은 우연히 올바른 라벨을 유지한다 — 와 일치하는 동작이다. 실제로 이때 학습 정확도(치환 후 라벨 기준)도 0.18 정도까지만 상승한다(기본 실행 시 0.1831. seed=0 계열에서는 약 0.19. 3.10).
** CONTROL: training labels SHUFFLED — model should NOT learn (expect ~0.10) **
epoch 1/40 test accuracy = 0.1046
epoch 40/40 test accuracy = 0.1608
...
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기