파일 시스템은 LLM을 어디서 만나는가: 모델 파일이 메모리로 올라가기까지
오늘의 질문
앞선 글들에서 NAND의 제약과 파일 시스템의 대응을 봤다. 그런데 요즘 저장장치 이야기가 가장 뜨거운 곳은 따로 있다. LLM이다.
여기서 의아할 수 있다.
LLM은 GPU가 계산하는 것 아닌가? 저장장치가 개입할 여지가 어디 있는가? 모델을 한 번 올려놓으면 그 다음부터는 파일과 무관하지 않나?
이 글은 그 접점을 찾는다. 결론부터 말하면 접점은 두 군데다. 하나는 모델을 올리는 순간이고, 다른 하나는 모델이 메모리에 다 안 들어갈 때다. 특히 메모리가 넉넉하지 않은 엣지 기기에서는 두 번째가 상시 조건이 된다.
먼저 답하기
세 문장으로 줄이면 이렇다.
LLM 추론은 가중치 파일을 통째로 읽어 메모리에 올려두고, 토큰마다 그 전체를 다시 훑는 작업이다. 그래서 첫 토큰이 나오기까지의 시간은 계산이 아니라 파일을 올리는 시간에 크게 좌우된다. 파일 형식과 읽는 방식을 바꾸면 이 시간이 몇 배씩 달라진다.
이 글은 두 부분으로 나뉜다. 앞쪽은 모델 안에서 무슨 일이 일어나는지를 훑어 왜 가중치를 계속 읽어야 하는지 보이고, 뒤쪽은 그 가중치가 담긴 파일을 어떻게 다루는지를 다룬다.
주제에 맞는 핵심 설명
모델 하나를 통과하는 경로
먼저 계산 쪽을 짚어야 저장장치 쪽 이야기가 이해된다.
메타가 공개한 Llama 3는 트랜스포머 구조를 기반으로 한 오픈 웨이트 모델이다. 가중치가 공개되어 있어 누구나 내려받아 직접 돌릴 수 있고, 그래서 엣지 기기에서 LLM을 돌리는 논의의 기준점 역할을 해왔다. 구조 자체는 앞선 글에서 다룬 트랜스포머 디코더와 같은 계열이다.
이 그림이 없으면 “가중치를 읽는다”는 말이 어느 단계에서 몇 번 일어나는지 감이 오지 않는다. 블록 하나를 열어보면 다섯 단계가 순서대로 나온다.
① Q·K·V 만들기. 입력 벡터에 학습된 행렬 세 개를 각각 곱해 세 종류의 벡터를 만든다. Q(query)는 “지금 무엇을 찾는가”, K(key)는 “나는 어떤 존재인가”, V(value)는 “찾아졌을 때 무엇을 건네는가”를 나타낸다. 셋 다 같은 입력에서 나오지만 통과한 행렬이 다르므로 서로 다른 벡터가 된다.
② 관련도 점수 만들기. Q와 앞선 모든 토큰의 K를 내적해 점수를 낸다. 그 점수를 벡터 차원의 제곱근으로 나눈 뒤 softmax를 거치면 합이 1인 가중치가 된다.
③ V를 섞기. 그 가중치만큼 각 토큰의 V를 더한다. 가장 관련 있는 것 하나를 고르는 게 아니라 전부를 비율대로 섞는다.
④ 출력 정리. 여러 갈래로 나눠 계산한 결과를 이어붙이고 선형 변환을 한 번 거쳐 원래 차원으로 되돌린다.
⑤ 피드포워드. 벡터를 훨씬 넓은 차원으로 폈다가 다시 접는다. 층 하나가 가진 파라미터의 대부분이 여기에 몰려 있다.
여기서 KV 캐시가 왜 생기는지도 보인다. ②에서 필요한 것은 앞선 토큰들의 K와 V인데, 이것들은 다음 토큰을 만들 때도 똑같이 필요하다. 문장 앞부분이 바뀐 게 아니기 때문이다. 그래서 매번 다시 계산하지 않고 저장해둔다. 그 저장소가 KV 캐시이며, 토큰이 늘 때마다 층마다 쌓인다.
저장장치 관점에서 이 그림을 다시 읽어보자. ①, ④, ⑤에 등장하는 행렬은 전부 학습된 값이고, 처음에 파일에서 읽어온 것이다. 그리고 토큰 하나를 만들 때 이 행렬들이 한 번씩 전부 필요하다. 모델 파일의 크기가 곧 매 토큰의 부담이 된다는 뜻이다.
프리필과 디코드: 같은 가중치, 정반대 성격
추론은 두 단계로 나뉜다.
프리필(prefill)은 사용자가 입력한 질문 전체를 한꺼번에 처리해 KV 캐시를 채우는 단계다. 디코드(decode)는 그 뒤에 답변을 한 토큰씩 만들어내는 단계다.
두 단계는 같은 가중치를 쓰는데 하드웨어 입장에서는 성격이 정반대다.
차이의 원인은 한 번에 처리하는 토큰 수다.
프리필은 토큰 512개를 동시에 처리하므로 계산이 행렬 × 행렬 형태가 된다. 이를 GEMM(General Matrix Multiply)이라 부른다. 디코드는 토큰 하나만 처리하므로 행렬 × 벡터가 되고, 이를 GEMV(General Matrix-Vector multiply)라 부른다.
GEMM은 가중치 하나를 읽어와 512개 토큰 계산에 재사용한다. GEMV는 읽어와 한 번 곱하고 버린다. 같은 읽기량에서 뽑아내는 계산량이 512배 차이 난다.
숫자로 확인해보자.
가정 — 파라미터 8×10⁹개, 가중치 FP16(2 B/param). 계산량은 파라미터당 곱셈 1회 + 덧셈 1회로 잡는다.
식 — 연산 강도 = 2 × P × T ÷ (P × 2) = T. 즉 한 번에 처리하는 토큰 수가 그대로 연산 강도가 된다.
| 한 번에 처리하는 토큰 수 T | 계산량 | 가중치 읽기 | 연산 강도 | 형태 |
|---|---|---|---|---|
| 1 (디코드) | 0.02 TFLOP | 16 GB | 1 FLOP/B | GEMV |
| 8 | 0.13 TFLOP | 16 GB | 8 FLOP/B | GEMM |
| 32 | 0.51 TFLOP | 16 GB | 32 FLOP/B | GEMM |
| 128 | 2.05 TFLOP | 16 GB | 128 FLOP/B | GEMM |
| 512 (프리필) | 8.19 TFLOP | 16 GB | 512 FLOP/B | GEMM |
적용 한계 — 위 식은 가중치 트래픽만 센 단순 모델이다. KV 캐시 접근과 어텐션 자체의 계산은 빠져 있고, 컨텍스트가 길어지면 KV 쪽이 따로 커진다. 또 실제 달성 성능은 커널 구현과 메모리 접근 패턴에 좌우된다. 이 표는 부등호의 방향과 배율의 감각을 보기 위한 것이다.
디코드에서 연산 강도가 1 FLOP/B라는 것은 앞선 글에서 본 그대로다. 연산 유닛은 대부분 놀고 있고, 시간은 가중치를 읽는 데 쓰인다. 이렇게 노는 연산 자원을 유휴 상태라고 부른다.
그래서 서빙에서는 여러 요청의 GEMV를 모아 GEMM으로 바꾸는 전략이 기본이 된다. 같은 가중치를 읽는 김에 여러 요청이 나눠 쓰면 위 표에서 아래쪽으로 내려가는 효과가 난다. 다만 엣지 기기처럼 요청이 하나뿐인 환경에서는 모을 것이 없다. 엣지에서 디코드가 특히 답답한 이유가 이것이다.
그래서 파일이 문제가 된다
여기까지가 계산 쪽 이야기다. 이제 파일로 넘어간다.
LLM 워크로드의 입출력 패턴은 일반적인 서버 워크로드와 성격이 다르다. 세 가지로 정리할 수 있다.
| 구분 | 일반 워크로드 | LLM 학습 | LLM 추론 |
|---|---|---|---|
| 주된 접근 | 작고 잦은 랜덤 읽기·쓰기 | 대용량 데이터셋 순차 읽기 | 모델 파일 대용량 순차 읽기 |
| 반복성 | 매번 다른 데이터 | 에폭마다 전체를 다시 훑음 | 같은 파일을 매번 다시 읽음 |
| 쓰기 비중 | 높음 | 체크포인트 저장 | 거의 없음 |
| 병목 지점 | 지연 시간 | 데이터 공급 속도 | 모델 로딩 시간 |
추론 쪽 마지막 줄이 핵심이다. 추론은 사실상 읽기 전용 워크로드이고, 그것도 매번 같은 파일을 처음부터 읽는 패턴이다. 이 특성이 뒤에 나올 최적화의 근거가 된다.
모델 크기가 커지면서 이 부담도 커졌다. 파라미터 8×10⁹개짜리 모델도 FP16이면 16 GB다.
가정 — 파라미터 8×10⁹개, NVMe 읽기 속도 3 GB/s.
| 정밀도 | 파일 크기 | 로드 시간 |
|---|---|---|
| FP32 (32비트) | 32 GB | 10.7 s |
| FP16 (16비트) | 16 GB | 5.3 s |
| 8비트 | 8 GB | 2.7 s |
| 4비트 | 약 4 GB | 약 1.4 s |
적용 한계 — 3 GB/s는 설명용 가정값이며 실제 장치·인터페이스에 따라 크게 다르다. 4비트 파일은 스케일 같은 부가 정보 때문에 단순 계산보다 조금 커진다. 또 실제 로딩 시간에는 파일 읽기 외에 자료구조 구성과 장치 전송이 더해진다.
여기서 양자화(quantization)가 등장한다. 학습할 때 쓰던 16비트 숫자를 8비트나 4비트로 줄여 저장하는 것이다. 표현할 수 있는 값의 가짓수가 줄어드니 정확도를 조금 잃지만, 파일 크기와 읽기량이 그 비율만큼 줄어든다. 앞 표에서 봤듯 디코드는 읽기가 병목이므로, 읽기량이 절반이면 속도가 대략 그만큼 개선된다. 엣지에서 양자화가 선택이 아니라 전제 조건에 가까운 이유다.
파일 형식이 읽는 방식을 결정한다
같은 가중치라도 어떤 형식으로 저장하느냐에 따라 읽는 방법이 달라진다. 세 가지를 비교해보자.
pickle 기반 형식은 파이썬의 직렬화 방식을 그대로 쓴 것으로, PyTorch의 .bin이나 .pt 파일이 여기 해당한다. 문제는 이 형식이 객체를 복원하는 명령과 데이터가 뒤섞인 스트림이라는 점이다. 이 때문에 두 가지 부담이 생긴다. 불러오는 과정에서 임의의 코드가 실행될 수 있어 신뢰할 수 없는 파일을 열기 위험하고, 텐서 하나만 골라 읽기도 어렵다.
safetensors는 이 문제를 해결하려고 허깅페이스에서 만든 형식이다. 관련 자료들에 따르면 이 형식은 JSON 헤더와 원시 텐서 데이터로만 구성되며, 읽는 과정이 JSON 파싱과 메모리 매핑뿐이라 코드가 실행될 여지가 구조적으로 없다. 헤더에는 각 텐서의 이름, 모양, 자료형, 그리고 파일 안에서의 위치가 적혀 있다. 이 헤더는 큰 모델이라도 보통 1 MB 미만이다.
GGUF는 llama.cpp 프로젝트에서 만든 형식으로, 자료들에 따르면 2023년 8월에 이전의 GGML 형식을 대체하며 등장했다. 특징은 양자화를 형식 자체에 품고 있다는 점이다. 모델 가중치, 구조 정보, 토크나이저, 추론 파라미터를 한 파일에 담고, 텐서마다 다른 양자화 방식을 쓸 수 있다. 헤더에 키-값 형태의 메타데이터가 들어 있어서, 전체를 읽지 않고 헤더만 봐도 구조와 양자화 종류를 알 수 있다.
두 형식을 나란히 놓으면 이렇다.
| 축 | safetensors | GGUF |
|---|---|---|
| 주된 용도 | 학습·파인튜닝·배포 | 엣지·CPU 추론 |
| 저장 정밀도 | 원래 정밀도 그대로 (보통 FP16/BF16) | 양자화된 값 |
| 양자화 | 형식이 직접 수행하지 않음 | 형식에 내장, 텐서별로 다르게 가능 |
| 부속 파일 | 구조 정보는 별도 JSON에 두는 경우가 많음 | 토크나이저까지 한 파일에 |
| 헤더 파싱 | JSON 파서 필요 | 이진 키-값, 저수준 언어에 유리 |
| 위치 사전 파악 | 가능 | 가능 |
| 메모리 매핑 | 가능 | 가능 |
정리하면 safetensors는 파이프라인의 앞쪽, GGUF는 뒤쪽에 있다. 학습과 배포는 safetensors로 하고, 엣지에서 돌릴 때 GGUF로 변환하면서 양자화하는 흐름이 일반적이다. 실제로 GGUF 변환 도구는 safetensors를 입력으로 받는 경우가 많다.
여기서 저장장치 관점의 공통점 하나가 중요하다. safetensors와 GGUF는 둘 다 “어느 텐서가 파일의 어디에 있는지”를 헤더에서 미리 알 수 있다. 이 성질이 다음 절을 가능하게 만든다.
mmap: 읽지 않고 걸어두기
파일을 메모리로 올리는 전통적인 방법은 read()다. 파일 전체를 읽어 프로세스의 버퍼에 담는다. 그런데 이 경로에는 복사가 두 번 일어난다. 디스크에서 커널의 페이지 캐시로 한 번, 페이지 캐시에서 프로세스 버퍼로 또 한 번이다. 결과적으로 같은 내용이 메모리에 두 벌 존재한다.
mmap은 다른 방식이다. 파일을 프로세스의 가상 주소 공간에 걸어두기만 한다. 이 시점에는 아무것도 읽지 않는다. 나중에 그 주소에 실제로 접근하는 순간, 운영체제가 해당 페이지만 디스크에서 가져온다. 프로세스는 페이지 캐시를 그대로 들여다보므로 추가 복사가 없다.
llama.cpp가 이 방식을 택한 이유가 잘 정리되어 있다. 프로젝트에 mmap을 도입한 작업을 설명한 글에 따르면, 개선의 핵심은 mmap이 페이지 복사를 피한다는 데 있다. 복사된 메모리가 늘어나면 커널이 파일 캐시를 밀어내게 되고, 그러면 다음 실행 때 또 디스크에서 느리게 읽어야 한다. 같은 글은 이 변경으로 20 GB 모델을 안전하게 올리는 데 필요하던 40 GB 대신 20 GB면 되게 되었다고 설명한다. 그리고 파일이 커널 페이지 캐시에 남아 있으므로 다음 실행부터는 즉시 로드된다.
현재 llama.cpp는 모델 로딩 방식을 옵션으로 노출하고 있으며, 공식 문서 기준으로 기본값이 auto(장치가 지원하지 않는 경우를 제외하면 mmap)이고, mmap을 끄면 로딩이 더 느려진다고 안내한다. 함께 제공되는 옵션으로는 모델을 강제로 RAM에 붙들어두는 mlock, 둘을 함께 쓰는 mmap+mlock, 그리고 DirectIO를 쓰는 dio가 있다.
두 방식을 정리하면 이렇다.
| 축 | read() | mmap() |
|---|---|---|
| 메모리 복사 | 2회 (캐시 → 버퍼) | 1회 (캐시까지만) |
| 메모리 사용량 | 모델 크기만큼 별도 확보 | 별도 확보 없음 |
| 시작 시점 | 전량 읽기 완료 후 | 즉시 시작, 필요한 페이지만 나중에 |
| 재실행 | 캐시가 밀렸으면 다시 전량 읽기 | 캐시가 살아 있으면 거의 즉시 |
| 프로세스 간 공유 | 각자 별도 사본 | 같은 캐시를 공유 |
| 접근 비용 | 미리 다 냄 | 접근할 때 페이지 폴트로 분산 |
mmap이 항상 유리한 것은 아니다. 결국 전 구간을 다 읽는다면 총 읽기량은 같고, 페이지 폴트 처리 비용이 계산 도중에 흩어져 들어간다. 메모리가 부족하면 이미 올라온 페이지가 밀려나 같은 구간을 다시 읽을 수도 있다. llama.cpp가 mlock 옵션을 함께 제공하는 이유가 이것이다.
앞선 글들과 이어보면
여기서 NAND 이야기가 다시 걸린다.
첫째, 추론은 대용량 순차 읽기다. 앞선 글에서 봤듯 순차 접근은 SSD가 가장 잘 처리하는 패턴이고, 정렬이 맞아 있으면 읽기 증폭도 없다. 모델 파일을 통째로 두는 방식이 저장장치 입장에서 나쁘지 않은 이유다.
둘째, 쓰기가 거의 없다. 가비지 컬렉션 부담이 적고, 모델 파일이 차지한 블록은 오래 유효한 상태로 남는다. 앞선 글의 유효 비율 u 관점에서 보면 안정적인 영역이다.
셋째, mmap은 페이지 단위 랜덤 읽기를 만든다. 순차 읽기와 성격이 달라지는 지점이다. 다만 읽기이므로 쓰기 증폭과는 무관하고, 부담은 지연 시간 쪽으로 간다.
넷째, 양자화는 읽어야 할 바이트를 직접 줄인다. 앞선 글에서 정리한 세 갈래 중 “옮길 바이트를 줄인다”에 정확히 해당한다. 엣지에서 이것이 가장 효과가 큰 이유는, 배칭으로 재사용을 만들 수 없어서 나머지 두 갈래가 막혀 있기 때문이다.
자주 묻는 질문
모델을 한 번 올리면 그 다음부터는 저장장치와 무관한 것 아닌가요? 메모리에 전부 올라간다면 대체로 맞다. 문제는 그게 안 되는 경우다. 모델이 메모리보다 크면 일부를 저장장치에 남겨두고 필요할 때 가져오는 방식이 되는데, 이때는 토큰마다 저장장치 접근이 생긴다. 엣지 기기에서는 이 상황이 예외가 아니라 기본값에 가깝다. 그리고 메모리에 다 올라가는 경우에도 처음 올리는 시간은 여전히 남는다. 모델을 자주 바꿔 끼우거나 프로세스를 자주 재시작하는 환경에서는 이 시간이 반복해서 발생한다.
양자화하면 품질이 얼마나 떨어지나요? 비트 수와 방식에 따라 다르고, 모델과 작업 종류에 따라서도 다르다. 일반적으로 8비트는 손실이 작고 4비트부터 눈에 띄기 시작한다고 알려져 있지만, 정확한 정도는 실제로 측정해봐야 안다. 실무적으로는 목표 작업에서 허용 가능한 품질 하한을 먼저 정하고, 그 선을 넘지 않는 가장 낮은 비트를 고르는 순서가 편하다. GGUF처럼 텐서마다 다른 정밀도를 쓸 수 있는 형식은 민감한 층만 높은 정밀도로 두는 절충도 가능하다.
pickle 형식 파일은 쓰면 안 되나요? 출처를 신뢰할 수 있는 내부 파일이라면 실무에서 여전히 쓰인다. 다만 인터넷에서 받은 가중치를 그대로 여는 것은 위험하다. 불러오는 과정에서 코드가 실행될 수 있기 때문이다. PyTorch가 텐서 데이터만 복원하도록 제한하는 옵션을 제공하지만, 이는 선택적으로 켜야 하는 완화책이지 형식 자체가 바뀐 것은 아니다. 공개 배포용이라면 safetensors 쪽이 안전하다.
엣지 기기에서 로딩 시간을 줄이려면 무엇부터 손대야 하나요? 순서를 정하자면 이렇다. 첫째, 파일 크기를 줄인다. 양자화가 가장 직접적이고, 위 표에서 봤듯 크기에 비례해 시간이 줄어든다. 둘째, 읽는 방식을 확인한다. mmap을 쓰고 있는지, 껐다 켰을 때 차이가 있는지 실제로 재본다. 셋째, 페이지 캐시가 유지되는지 본다. 프로세스를 자주 재시작하는 구조라면 캐시가 살아 있어 두 번째 실행부터 빨라지는 효과를 누릴 수 있지만, 메모리 압박이 크면 매번 밀려난다. 이 경우 mlock 같은 옵션을 검토할 수 있다. 넷째, 그래도 부족하면 파티션 정렬과 파일 배치를 확인한다. 앞선 글에서 다룬 항목들이며, 대용량 순차 읽기에서는 영향이 작지만 확인 비용도 작다.
같은 모델을 여러 프로세스가 동시에 돌리면 메모리를 그만큼 더 쓰나요?
읽는 방식에 달려 있다. read()로 각자 올리면 프로세스 수만큼 사본이 생긴다. mmap을 쓰면 같은 파일을 매핑한 프로세스들이 같은 페이지 캐시를 공유하므로 사본이 하나로 유지된다. 모델을 여러 인스턴스로 서빙하는 구성이라면 이 차이가 크게 작용한다. 다만 KV 캐시처럼 요청마다 달라지는 데이터는 공유되지 않으므로, 프로세스가 늘면 그쪽 메모리는 그대로 늘어난다.