Загружаем каталог…
Загружаем каталог…
SW마에스트로 프로젝트를 하며 작성하는 회고입니다. 이전 글 에서 하이브리드 검색의 키워드 비중을 한국어 10%, 영어 40%로 바꿨다. 그런데...운영 서버는 메모리 문제로 임베딩 모델을 꺼 두고 키워드 검색(BM25)만 쓰고 있었다는 것...이였다..그래서 임베딩 모델을 운영에 올리려고 했더니 메모리 한도가 한계에 근접해 있었다. 양자화를 통해 메모리 한도의 문제를 해결하고자 했지만, 실패한 이야기를 작성해두려고한다. 양자화하면 되겠네! 프로젝트에 다국어 임베딩 모델로 BGE-M3를 쓴다. 특히, 한국어 임베딩이 중요해 벤치마크 결과 상으로 가장 좋은 모델을 선택해서 사용하고 있었다. 실제로 한국어 문서 평가셋 66문항에서 BM25만 쓰면(키워드 검색) 43개 , BGE-M3를 켜면(하이브리드 검색) 57개 를 맞혔다. 문제는 메모리였다. AI 작업자 Pod의 메모리 한도는 1Gi 근처였는데, BGE-M3는 파라미터 5.68억 개에 fp32(숫자 하나당 4바이트짜리 소수) 가중치만 2.1GB 에 달했다. 그래서 가장 먼저 떠올린 게 int8 양자화 였다. 가중치를 4바이트에서 1바이트로 줄이면 2.1GB가 0.5GB 남짓이 된다. 계산대로라면 분명 양자화로 이 문제를 해결할 수 있겠다고 생각했다...그랬다... (int8 양자화 막간 설명) 숫자들을 1바이트짜리 정수(int8)로 바꾸는 것! 0.0123456 처럼 소수점 아래까지 적던 값을 -128~127 사이 눈금 중 가장 가까운 칸으로 옮기는 느낌? 사진을 저화질로 저장하면 용량이 줄듯이, 압축한다는 느낌이랑 비슷 양자화를 하니 왜 전부다 망가졌지? 양자화를 위해서 PyTorch의 동적 양자화를 썼다. 모델을 불러온 뒤 Linear 와 Embedding 층을 int8로 바꾸는 방식이다. model = SentenceTransformer("BAAI/bge-m3", device="cpu") model = quantize_dynamic( model, {torch.nn.Linear: default_dynamic_qconfig, torch.nn.Embedding: float_qparams_weight_only_qconfig}, dtype=torch.qint8, inplace=True, ) 양자화의 효과가 있는지 확인해보기 위해서 이전 글(Jev실험)에서 만든 한영 교차 세트를 그대로 썼다. Wiki 근거 2,157개와 정답 있는 160문항의 한국어·영어 질문 쌍이다. 운영 AI 노드에 맞춰 CPU 4스레드로 돌렸다. fp32 int8 모델 로드 후 메모리 0.92GB 4.54GB 최고 메모리 5.58GB 7.83GB 근거 2,157개 임베딩 시간 233초 579초 한국어 질문: 필수 근거 상위 20개 86/160 79/160 영어 질문: 필수 근거 상위 20개 153/160 152/160 왜 이렇게 됐을까...? 메모리를 줄이려고 양자화했는데 로드 후 메모리가 5배 가까이 늘었다. 속도는 2.5배 느려졌고 한국어 검색 품질까지 떨어졌다. 하나라도 좋아진 게 있으면 고민이라도 했을 텐데 전부 나빠졌다... 여기서 메모리가 늘었다는 게 젤 납득이 안 돼서 이유를 알아보기로 했다. 수치 측정의 시점 문제 평가 결과를 보니 처음부터 이상한 숫자가 있었다. fp32 가중치가 2.1GB인데 로드 직후 메모리가 0.92GB 다. 가중치보다 메모리가 적을 수가 있나? 뭔가 이상한데? 그래서 단계마다 프로세스 메모리(RSS)와 모델 안에 남은 가중치 크기를 같이 재 봤다. 단계 RSS 모델 안의 가중치 fp32 로드 직후 0.91GB fp32 2.12GB 가중치를 한 번씩 읽은 뒤 3.02GB fp32 2.12GB 양자화 후 4.53GB fp32 0GB , int8 0.53GB 모델 파일 형식인 safetensors 는 파일을 메모리에 통째로 복사하지 않고 매핑 만 해 둔다.(약간 운영체제에서 가상메모리를 요구 페이징 기법으로 사용하는 느낌? 실제로 사용할 때만 메모리를 씀.) 그래서 로드 직후에는 가중치 대부분이 아직 메모리에 없었던 것이다...! 실제로 모든 가중치를 한 번 읽게 하자 바로 3.02GB가 됐다. fp32도 인코딩을 한 번 돌린 뒤에는 2.15GB까지 올라가 있었다. 애초에 "0.92GB"라는 수치부터가 오류였다! 근데 그래도 메모리가 4.53GB인건 선넘는데? 왜 이렇게 메모리를 많이 사용했지? 집에 가지 못한 원본 양자화는 원본 가중치를 읽어서 int8 사본을 만드는 작업이다. 그러니 fp32 파일 2.1GB가 전부 메모리에 올라온다. 이상한 건 그다음이었다. 양자화가 끝난 뒤 모델 안의 fp32 행렬 가중치는 하나도 남지 않았다. 그런데 vmmap 으로 메모리를 들여다보니 모델 파일 매핑 2.1GB가 해제되지 않고 그대로 올라와 있었다. (어째서..?) 거기에 변환하면서 쓴 임시 메모리 약 1.0GB도 메모리 할당자가 운영체제에 돌려주지 않고 들고 있었다.(너는 왜그래?) 정리하면 양자화 후 4.5GB는 이렇게 채워져 있었다. fp32 원본 파일 매핑: 2.1GB int8 가중치: 0.5GB 변환 중 쓰고 남은 빈 메모리: 1.0GB 0.5GB짜리 모델을 쓰려고 4.5GB를 들고 있었던 셈이다... 매핑이가 너무 좋았던 텐서 모델 안의 숫자들은 텐서라는 배열에 나눠 담겨 있다. 그래서 먼저 범인이 모델을 불러온 코드(로더) 쪽인지, 이 텐서 쪽인지를 알아내고자 했다. 양자화 없이 모델을 지우거나 가중치를 전부 복사본으로 바꾸면 매핑은 바로 풀렸다. 즉 매핑을 잡고 있는 건 로더가 아니라 파일을 가리키는 텐서 로 판명이 났다. 쉽게 설명해보자면, 매핑이 책 한 권이라면 각 텐서는 그 책의 특정 쪽에 꽂힌 책갈피라고 볼 수 있다. 다르게 말하면 책갈피가 꽃혀있으려면 책이 있어야 한다는 것이다. 즉, 파일에서 나온 텐서가 단 하나라도 살아 있으면 매핑 전체가 해제되지 않는다. 그럼 양자화 후에도 원본 파일을 가리키는 텐서가 남아 있다는 얘기다. 하나씩 추적해 보니 범인은 양자화되지 않은 작은 값들 이었다. 모델 가중치에는 큰 행렬만 있는 게 아니다. 행렬 계산 결과에 더해 주는 보정값(bias)도 있고, 레이어 사이에서 값의 크기를 일정하게 맞춰 주는 LayerNorm의 값도 있다. 양자화는 메모리를 거의 다 차지하는 큰 행렬만 int8로 바꾸고, 이런 작은 값들은 fp32 원본 그대로 둔다. 작아서 줄여 봐야 이득이 없고, 정밀도를 낮추면 오차가 커지기 쉽기 때문이다. Linear bias(행렬 계산에 더하는 보정값): 145개 LayerNorm weight·bias(레이어 사이 값을 정규화하기 위한 값): 98개 전부 합쳐도 약 1.2MB 다. 문제는 이 값들이 여전히 원본 파일 매핑 안을 가리키고 있다 는 거다. 다시 책으로 비유해보면...필요한 내용은 전부 요약 노트(int8)로 옮겨 적었다! 그런데 몇 쪽은 옮겨 적지 않고 책에 포스트잇을 붙여 둔 채 계속 펼쳐 보고 있다. 그러면 노트가 있어도 책을 반납할 수 없다... 매핑이 딱 이런 상태였다. 파일 2.1GB를 하나의 덩어리로 빌려 오기 때문에, 그 안을 가리키는 텐서가 단 하나라도 남아 있으면 덩어리 전체를 돌려줄 수 없다. 게다가 양자화하면서 모든 쪽을 한 번씩 펼쳐 봤으니, 2.1GB가 전부 메모리에 올라온 채로 남아 있던 것이었다! 정말 이게 원인인지 확인까지 했다. 단계 파일 매핑 RSS 양자화 직후 2.1GB 상주 4.53GB Linear bias 145개만 복사본으로 교체 그대로 4.53GB 남은 LayerNorm 파라미터까지 복사 해제 2.41GB bias만 바꿨을 때는 그대로였다. LayerNorm까지 떼어 내자 그제야 매핑이 풀리고 2.1GB가 빠졌다. 텐서가 하나라도 남아있으면 매핑이 안 풀린다는걸 새로 알게되었다!! 그런데 2.41GB에도 반납 안 된 빈 메모리 1GB가 그대로 들어 있다. 그리고 이렇게 공들여 줄여도 뒤에 나올 fp32 배치 1의 최고 메모리(2.52GB)와 큰 차이가 없다...그럼 Terraform의 메모리를 늘리는 방법말곤 없나..? 줄일 수 있는게 아직 있잖아! 이게 제일 중요한 발견이었다. fp32의 로드 후 메모리(0.92GB)와 최고 메모리(5.58GB)는 4.66GB 나 차이 난다. 이 메모리는 가중치가 아니라 인코딩 중에 생기는 중간 계산값(activation) 이다. (약간 수학 문제를 풀 때 연습장에 적는 풀이 과정 같은 느낌? 문제(입력)가 길수록 연습장도 많이 필요하게 되는) BGE-M3는 입력을 최대 8,192토큰까지 받는다. 그리고 여러 입력을 묶어서(batch) 처리하면 묶음 안에서 가장 긴 입력에 길이를 맞춘다. 즉, 긴 근거 하나만 섞여도 16개 전부가 긴 입력처럼 계산되어버린다..! 그런데 동적 양자화는 가중치만 int8로 바꾸고 중간 계산값은 fp32로 둔다. 가장 큰 덩어리는 건드리지도 못하면서, 작은 덩어리를 줄이겠다고 원본까지 들고 있었던 것이다. 그래서 양자화 대신 배치를 16에서 1로 줄였다. 배치 16 배치 1 최고 메모리 5.58GB 2.52GB 근거 2,157개 임베딩 시간 233초 189초 검색 품질 기준 동일 (벡터 코사인 ≥ 0.9999996) 메모리는 절반 이하 로 줄었고 품질은 그대로였다. 심지어 더 빨라졌다! 짧은 입력이 긴 입력 길이에 맞춰 낭비하던 계산이 사라졌기 때문이다.(진작에 평가를 제대로 했어야 했던거 같은데...아직 부족하다는 생각이 많이 든다) 입력 길이를 512·1024토큰으로 자르는 방법도 시험해 봤지만, 긴 문서에서 한국어 검색 품질이 떨어져서 쓰지 않았다. 운영에는 이 수치를 기준으로 메모리 요청 2.5Gi, 한도 3.5Gi를 잡았다. 모델은 Docker 이미지에 넣어서 Pod마다 내려받지 않게 했다. 이렇게 해서 운영에도 임베딩 모델을 가져갈 수 있게 됐다!! 부록?) 1. 양자화를 하니 왜 한국어 질문만 품질이 떨어졌을까? int8 벡터는 fp32 벡터와 평균 코사인 유사도가 0.978 이다. 거의 같은 벡터인데 한국어 질문만 눈에 띄게 나빠졌다. 질문 언어 나빠진 문항 같은 문항 좋아진 문항 한국어 83 39 38 영어 30 113 17 이유는 키워드 비중에 있었다. 한국어 질문으로 영어 원문을 찾을 때는 키워드가 거의 안 겹쳐서 점수의 90%를 임베딩 에 맡긴다. 영어 질문은 키워드가 40%를 받쳐 준다. 실제로 키워드 비중을 100%로 두면 fp32와 int8 결과가 완전히 같다(둘 다 상위 20개 안에 13문항). 비슷한 근거 2천여 개 사이에서는 2% 남짓한 벡터 차이로도 순위가 뒤집히게 되니, 임베딩에 기대는 비중이 큰 쪽이 양자화로 인한 손실을 그대로 떠안은 것이라고 볼 수 있을 것 같다. 부록?) 2. 그럼 속도는 왜 느렸을까? int8이 2.5배 느렸던 것도 그냥 넘기기 찜찜했다. 짐작 가는 건 두 가지였다. 동적 양자화는 층을 지날 때마다 입력을 int8로 바꾸는데, 이 변환 비용 이 아낀 계산보다 컸다 int8 행렬곱 자체 가 느리다 그래서 근거 96개를 같은 조건(배치 16, CPU 4스레드)으로 인코딩하면서 torch.profiler 로 연산마다 걸린 시간을 나눠 봤다. 연산 fp32 int8 행렬곱 (Linear) 18.5초 66.5초 어텐션 22.4초 22.4초 입력을 int8로 변환 - 1.1초 그 외 2.2초 2.0초 합계 43.2초 92.0초 첫 번째 가설은 바로 탈락했다. 입력 변환은 1.1초로 전체의 1% 남짓이다. 어텐션은 양자화 대상이 아니라 그대로고, 늘어난 시간은 거의 전부 행렬곱 에서 나왔다. 같은 행렬곱이 fp32로는 18.5초, int8로는 66.5초. 계산량을 줄이려고 한 양자화가 오히려 행렬곱을 3.6배 느리게 만든 거다. 그럼 int8 행렬곱은 왜 느릴까? BGE-M3의 FFN 층과 같은 크기(1024 → 4096)의 행렬곱 하나만 떼어서 스레드 수를 바꿔 가며 다시 재 봤다. 스레드 fp32 int8 (qnnpack) 1 44ms (약 1,550 GFLOPS) 578ms (약 119 GFLOPS) 4 44ms 576ms 두 가지가 이상했다. fp32가 너무 빠르다. 스레드 하나로 1.5 TFLOPS(1초에 1.5조 번)가 나온다. CPU 코어 하나가 일반 SIMD 명령으로 낼 수 있는 속도를 훨씬 넘는다. Mac용 PyTorch는 fp32 행렬곱을 Apple의 Accelerate 라이브러리로 계산하는데( BLAS_INFO=accelerate ), 스레드 수와 상관없이 속도가 같은 걸 보면 Apple Silicon의 행렬 전용 연산 유닛 을 쓰는 것으로 보인다. int8은 스레드를 늘려도 그대로다. qnnpack은 원래 모바일 ARM 칩용으로 만든 int8 커널이라 이런 전용 유닛을 쓰지 않는다. 이번 환경에서는 스레드를 4개로 늘려도 빨라지지 않았다. 결국 Mac에서는 "int8 vs fp32"가 아니라 "범용 int8 커널 vs 행렬 가속기를 쓰는 fp32" 의 싸움이었던 거다. 애초에 이길 수가 없는 비교였다...(운영체제라는 빽이 있었구나..) 다만 이건 Mac에서만 그렇다는 얘기다. 운영 서버는 x86이고 int8도 다른 엔진(fbgemm)으로 계산한다. 그쪽 결과는 다를 수 있지만 직접 재 보지는 않았다. 어차피 한국어 품질 손실은 엔진과 상관없이 남기 때문에 양자화를 다시 꺼낼 이유는 없었다. 결론 양자화 실험 자체는 실패였다. 하지만 실패한 이유를 파고들면서 배운 것들이 많았고, 실패 원인을 분석하다가 해결책도 찾을 수 있었다! 결과적으로 코드 한 줄로 메모리를 절반 넘게 줄였다. 이번에 배운 건 네 가지정도로 정리해볼 수 있을 것 같다! 메모리를 줄이기 전에, 메모리가 어디에 쓰이는지부터 나눠 보자. 가중치가 크다는 것만 보고 양자화를 골랐는데, 실제 최고치를 만든 건 중간 계산값이었다. 측정값의 의미를 의심하자. fp32 로드 직후 0.92GB는 가중치가 아직 안 올라온 상태였다. 이걸 몰랐다면 "양자화하면 메모리가 5배 된다"는 이상한 결론을 냈을 것이다. "양자화하면 메모리가 늘어난다"는 일반론이 아니다. 정확히는 "fp32 모델을 올린 뒤 PyTorch 동적 양자화로 바꾸면 원본이 남는다"다. 미리 양자화해 둔 파일(ONNX int8 같은)을 바로 불러오면 결과가 다를 수 있다. 이건 다음에 꼭 시험해 볼 생각이다. 어디서 쟀는지가 결론을 바꾼다. Mac에서 int8이 느렸던 건 int8이 느려서가 아니라 fp32가 행렬 가속기를 썼기 때문이었다. 로컬 벤치마크 결과를 운영 환경에 그대로 옮기면 안 된다. 뭔가 항상 빠른 모델, 좋은 모델만 찾아다닌 것 아닌가?하는 생각이 들기도 한 과정이였다. 기존의 것들이 문제가 되는 원인을 정확히 분석하는게 더 선행되어야하는데 기본을 잃어버리고 있다고 생각이 들었다! 다시 초심으로 돌아가서 문제 정의를 확실히하여 해결 방법을 찾는 연습을 해야겠다!!
То, что RADAR обнаружил и классифицировал для этой возможности. Это опубликованный источником текст, а не подтверждение, что предложение ещё действует.
왜 양자화를 했는데 더 나빠졌지?. SW마에스트로 프로젝트를 하며 작성하는 회고입니다. 이전 글 에서 하이브리드 검색의 키워드 비중을 한국어 10%, 영어 40%로 바꿨다. 그런데...운영 서버는 메모리 문제로 임베딩 모델을 꺼 두고 키워드 검색(BM25)만 쓰고 있었다는 것...이였다..그래서 임베딩 모델을 운영에 올리려고 했더니 메모리 한도가 한계에 근접해 있었다. 양자화를 통해 메모리 한도의 문제를 해결하고자 했지만, 실패한 이야기를 작성해두려고한다. 양자화하면 되겠네! 프로젝트에 다국어 임베딩 모델로 BGE-M3를 쓴다. 특히, 한국어 임베딩이 중요해 벤치마크 결과 상으로 가장 좋은 모델을 선택해서 사용하고 있었다. 실제로 한국어 문서 평가셋 66문항에서 BM25만 쓰면(키워드 검색) 43개 ,…
Открыть источник