13년차의 서버실

서버, 인프라, 홈랩 기술 블로그

[태그:] LLM 양자화

  • [AI] CUDA 기반 LLM 양자화 벤치마크: BitsAndBytes vs AWQ 성능 비교

    [AI] CUDA 기반 LLM 양자화 벤치마크: BitsAndBytes vs AWQ 성능 비교

    [AI] CUDA 기반 LLM 양자화 기법 비교: BitsAndBytes vs AWQ 성능 벤치마크

    LLM 양자화는 이제 GPU 메모리가 빠듯한 환경에서 거의 필수처럼 다뤄집니다. 특히 CUDA 성능을 끝까지 끌어내고 싶다면 BitsAndBytes와 AWQ 중 뭘 먼저 볼지 한 번쯤 고민해보셨을 거예요. 저도 홈랩에서 모델 올릴 때 처음엔 “일단 돌아가면 됐지” 하고 시작했는데, 막상 추론 속도와 VRAM 사용량, 로딩 편의성까지 같이 보니까 선택 기준이 꽤 달라지더라고요.

    이번 글은 CUDA 기반 LLM 양자화 기법 비교라는 관점에서, BitsAndBytes와 AWQ를 실제 운영/실험 관점으로 풀어보는 글입니다. 특정 수치를 과장해서 보여드리기보다는, 어떤 조건에서 무엇이 유리한지, 벤치마크는 어떻게 잡아야 덜 헷갈리는지, 그리고 실전에서 어떤 삽질이 나오는지를 중심으로 정리해보겠습니다. 혹시 “왜 같은 4bit인데 속도가 다르지?” 같은 경험 있으신가요? 여기서 중요한 포인트가 바로 커널(kernel, GPU 연산 루틴)과 로딩 방식입니다.

    1. 왜 LLM 양자화 벤치마크가 중요한가

    쉽게 말해 양자화(Quantization, 저정밀도 변환)는 모델의 가중치(weight)를 더 작은 비트 폭으로 저장하고 계산하는 방식입니다. 보통은 메모리를 줄이기 위해 시작하지만, 실제로 써보면 목적이 하나가 아니거든요.

    • VRAM 절약: 같은 GPU에서 더 큰 모델을 띄우거나 배치(batch) 크기를 늘릴 수 있습니다.
    • CUDA 성능 최적화: 경우에 따라 추론 처리량(throughput)이 좋아질 수 있습니다.
    • 운영 단순화: 서버 한 대로도 테스트 범위를 넓힐 수 있습니다.
    • 비용 절감: 클라우드 GPU 사용 시 메모리 요구량이 줄어드는 효과가 있습니다.

    근데 여기서 함정이 있습니다. 메모리를 줄였다고 항상 더 빠른 건 아닙니다. 저도 처음엔 4bit면 무조건 이득인 줄 알았는데, 실제로는 디코딩 단계에서 토큰 생성 속도(token/s)가 기대보다 안 나오는 경우가 있었고, 반대로 로딩은 번거로워도 실제 서비스 응답은 더 안정적으로 나오는 조합도 있었습니다. 그래서 LLM 최적화에서는 “몇 bit냐”보다 “어떤 방식으로 quantize 했고, 어떤 CUDA 경로를 타느냐”가 더 중요합니다.

    LLM 양자화와 CUDA 성능 비교를 위한 BitsAndBytes와 AWQ 전체 아키텍처 개요 이미지

    BitsAndBytes와 AWQ가 CUDA GPU 위에서 각각 어떤 로딩 경로와 추론 경로를 사용하는지 보여주는 개요 이미지입니다.

    2. BitsAndBytes와 AWQ 개념을 쉽게 정리해보면

    2-1. BitsAndBytes란?

    BitsAndBytes는 Hugging Face 생태계에서 많이 쓰는 양자화 라이브러리입니다. 특히 Transformers(트랜스포머, 대규모 언어모델 로딩 프레임워크)와의 궁합이 좋아서, 모델을 비교적 쉽게 8bit 또는 4bit로 로딩할 수 있다는 장점이 있습니다. 실무에서 빠르게 PoC를 할 때 진입장벽이 낮은 편이죠.

    제가 직접 써본 결과 BitsAndBytes는 “일단 빨리 올려서 확인”하기에 정말 편했습니다. 코드 몇 줄로 들어가고, 모델 허브(Hub) 기반 워크플로우에도 잘 붙거든요. 대신 성능은 모델 구조, CUDA 환경, 커널 지원 상황에 따라 편차가 좀 있더라고요.

    2-2. AWQ란?

    AWQ는 Activation-aware Weight Quantization의 약자입니다. 이름 그대로 활성값(activation)을 고려해서 가중치 양자화를 설계하는 접근입니다. 실전에서는 사전 양자화된(pre-quantized) 모델 아티팩트를 로드해 추론하는 흐름으로 많이 접하게 됩니다.

    처음엔 이게 뭔가 싶었는데, 실제로 써보니까 AWQ는 “양자화 자체”보다도 서빙 시 추론 경로가 잘 맞느냐가 더 중요한 포인트였습니다. 특히 CUDA에서 최적화된 구현을 잘 타면, 체감 응답성이 좋아지는 경우가 꽤 있었습니다.

    2-3. 한눈에 보는 차이

    항목 BitsAndBytes AWQ
    접근 방식 런타임 로딩 중심의 저정밀도 적용 사전 양자화된 가중치 활용이 일반적
    도입 난이도 상대적으로 쉬움 모델/런타임 조합 확인 필요
    Hugging Face 연동 매우 편한 편 도구 체인에 따라 차이 있음
    벤치마크 포인트 로딩 편의성, 메모리 절감, 범용성 추론 성능, 서빙 효율, 커널 적합성
    적합한 상황 빠른 테스트, 개발 환경 서빙 튜닝, 성능 중심 검토

    3. 벤치마크를 어떻게 잡아야 덜 속을까

    여기서 많이들 실수하는 게, 단순히 “모델 뜨는지”만 보고 끝내는 겁니다. 근데 benchmark는 관측 기준이 명확해야 해요. 제가 보통 보는 항목은 아래 정도입니다.

    1. 로딩 시간: 모델 초기화와 첫 추론 준비까지 걸리는 시간
    2. VRAM 사용량: 모델 로딩 직후와 추론 중 피크 메모리
    3. 첫 토큰 지연: TTFT(Time To First Token, 첫 토큰 응답 시간)
    4. 지속 생성 속도: 초당 토큰 수(token/s)
    5. 출력 품질 안정성: 과도한 품질 저하나 이상 출력 여부

    중요한 건 같은 프롬프트, 같은 max_new_tokens, 같은 GPU, 같은 드라이버 조건으로 맞춰야 한다는 점입니다. 이거 안 맞추면 결과가 거의 의미가 없어요. 저도 예전에 캐시 상태 다른 줄 모르고 두 방식 비교했다가 완전히 엉뚱한 결론 낸 적이 있거든요. 정말 한 번 삽질했습니다. (웃음)

    4. CUDA 환경에서 BitsAndBytes 실전 구현

    먼저 BitsAndBytes 쪽입니다. 빠르게 비교 환경을 만들 때 가장 무난한 흐름입니다.

    4-1. 기본 설치

    python -m venv .venv
    source .venv/bin/activate
    pip install torch transformers accelerate bitsandbytes

    환경에 따라 PyTorch는 CUDA 빌드가 이미 맞춰져 있어야 합니다. 이 부분이 안 맞으면 양자화 이전에 GPU 자체를 못 잡는 경우가 생기거든요.

    4-2. 4bit 로딩 예시

    import time
    import torch
    from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
    
    model_id = "meta-llama/Llama-2-7b-chat-hf"
    
    bnb_config = BitsAndBytesConfig(
        load_in_4bit=True,
        bnb_4bit_quant_type="nf4",
        bnb_4bit_use_double_quant=True,
        bnb_4bit_compute_dtype=torch.float16,
    )
    
    start = time.time()
    tokenizer = AutoTokenizer.from_pretrained(model_id)
    model = AutoModelForCausalLM.from_pretrained(
        model_id,
        quantization_config=bnb_config,
        device_map="auto"
    )
    load_time = time.time() - start
    
    prompt = "Explain the difference between AWQ and BitsAndBytes in simple terms."
    inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
    
    gen_start = time.time()
    output = model.generate(**inputs, max_new_tokens=128)
    gen_time = time.time() - gen_start
    
    print("load_time_sec=", round(load_time, 2))
    print("gen_time_sec=", round(gen_time, 2))
    print(tokenizer.decode(output[0], skip_special_tokens=True))

    여기서 포인트는 NF4 설정과 compute dtype이에요. 실제로 써본 결과 이 조합은 비교 기준을 잡을 때 많이 사용하게 되더라고요. 물론 모델마다 최선의 조합은 다를 수 있습니다.

    BitsAndBytes 기반 LLM 양자화의 CUDA 메모리 로딩 흐름을 보여주는 이미지

    Python 코드에서 BitsAndBytes 4bit 로딩이 수행되고 CUDA 메모리에 모델이 배치되는 흐름을 보여주는 구성 이미지입니다.

    5. CUDA 환경에서 AWQ 실전 구현

    AWQ는 조금 더 “서빙용 경로를 염두에 둔 구성”으로 보는 편이 좋습니다. 구현 방식은 사용하는 런타임에 따라 다르지만, 아래처럼 사전 양자화 모델을 로드하는 식으로 접근할 수 있어요.

    5-1. 기본 설치 예시

    python -m venv .venv
    source .venv/bin/activate
    pip install torch transformers accelerate autoawq

    패키지 조합은 시점과 환경에 따라 달라질 수 있어서, 실제 적용할 때는 프로젝트에서 쓰는 Transformers 계열과의 호환성을 먼저 확인하시는 게 좋습니다.

    5-2. AWQ 모델 로딩 예시

    import time
    from transformers import AutoTokenizer
    from awq import AutoAWQForCausalLM
    
    model_id = "TheBloke/zephyr-7B-beta-AWQ"
    
    start = time.time()
    tokenizer = AutoTokenizer.from_pretrained(model_id)
    model = AutoAWQForCausalLM.from_quantized(
        model_id,
        fuse_layers=True,
        trust_remote_code=True
    )
    load_time = time.time() - start
    
    prompt = "Summarize when AWQ is useful for CUDA inference."
    inputs = tokenizer(prompt, return_tensors="pt")
    
    gen_start = time.time()
    output = model.generate(**inputs, max_new_tokens=128)
    gen_time = time.time() - gen_start
    
    print("load_time_sec=", round(load_time, 2))
    print("gen_time_sec=", round(gen_time, 2))
    print(tokenizer.decode(output[0], skip_special_tokens=True))

    AWQ는 구현에 따라 fused layer(연산 결합) 같은 옵션이 성능에 영향을 주기도 해요. 그래서 단순히 “4bit라서 비슷하겠지”라고 보면 안 되고, 어떤 런타임 경로를 탔는지를 꼭 같이 확인해야 합니다.

    6. 제가 실제로 자주 보는 벤치마크 절차

    이 부분은 꽤 중요합니다. LLM 양자화 benchmark를 제대로 하려면 절차를 고정해야 하거든요.

    1. 동일한 GPU와 동일한 CUDA 드라이버 환경을 준비합니다.
    2. 같은 계열 모델 크기끼리 비교합니다.
    3. 프롬프트 길이와 출력 길이를 고정합니다.
    4. 첫 실행은 워밍업(warm-up)으로 버립니다.
    5. 2회차 이상부터 평균 응답 시간을 기록합니다.
    6. <code>nvidia-smi로 VRAM 사용량 피크를 같이 봅니다.
    7. TTFT와 token/s를 분리해서 기록합니다.
    watch -n 1 nvidia-smi
    CUDA_VISIBLE_DEVICES=0 python benchmark_bnb.py
    CUDA_VISIBLE_DEVICES=0 python benchmark_awq.py

    실제로 써본 결과 AWQ는 지속 생성 속도에서 체감이 괜찮은 경우가 있었고, BitsAndBytes는 세팅 편의성에서 확실히 이점이 컸습니다. 다만 이건 어디까지나 일반적인 경향에 가깝고, 모델 아키텍처와 커널 지원에 따라 결과가 바뀔 수 있거든요. 여기서 중요한 포인트! 벤치마크 결과를 일반화하기 전에, 내 워크로드가 긴 입력 위주인지 짧은 질의응답 위주인지 먼저 구분해야 합니다.

    CUDA 성능 기준으로 AWQ와 BitsAndBytes를 벤치마크하는 LLM 양자화 실행 이미지

    동일한 CUDA 환경에서 두 양자화 방식을 번갈아 실행하고 GPU 메모리와 추론 시간을 관찰하는 장면을 표현한 이미지입니다.

    7. ⚠️ 트러블슈팅: 여기서 많이 막힙니다

    7-1. GPU는 잡히는데 성능이 생각보다 안 나오는 경우

    이건 정말 흔해요. 원인은 여러 가지인데, 보통은 아래 범주로 묶입니다.

    • CUDA 커널 최적화 경로 차이: 같은 4bit라도 내부 구현 차이가 큽니다.
    • 모델별 적합성 차이: 어떤 모델은 특정 양자화 포맷과 더 잘 맞습니다.
    • 입력 길이 편향: 짧은 질의에서는 차이가 덜 보일 수 있습니다.
    • 비교 조건 불일치: 캐시 상태, 배치 크기, dtype이 다르면 결과가 흔들립니다.

    저도 처음엔 AWQ가 무조건 빠를 줄 알았는데, 짧은 프롬프트 위주 테스트에서는 체감 차이가 거의 없었던 적이 있어요. 반대로 긴 생성 구간에서는 차이가 보이기도 했고요. 그래서 벤치마크는 꼭 실제 사용 패턴과 비슷하게 잡아야 합니다.

    7-2. 설치는 되는데 import 에러가 나는 경우

    대개는 패키지 호환성 문제예요. Transformers, PyTorch, 양자화 라이브러리 버전 축이 어긋나면 바로 티가 납니다. 이럴 땐 아래 순서로 점검하면 됩니다.

    1. torch.cuda.is_available() 확인
    2. python -c "import torch; print(torch.__version__)" 확인
    3. 양자화 라이브러리 import 단독 테스트
    4. 가상환경을 새로 만들어 최소 패키지로 재현

    7-3. 메모리는 줄었는데 품질이 흔들리는 경우

    이건 양자화에서 완전히 피할 수 없는 주제입니다. 특히 공격적으로 메모리를 줄이면 일부 태스크에서 출력 품질이 흔들릴 수 있거든요. 그래서 성능만 보지 말고 간단한 품질 회귀 테스트(regression test, 성능 변경 후 품질 저하 확인)도 같이 돌려보셔야 합니다.

    8. 검증 결과는 어떻게 읽어야 하나

    수치 자체보다 방향성이 더 중요합니다. 제가 보통 내리는 결론은 아래처럼 정리됩니다.

    판단 질문 BitsAndBytes가 유리한 경우 AWQ가 유리한 경우
    빨리 실험해야 하나? 예 보통은 아님
    모델 허브 기반 워크플로우인가? 대체로 예 환경에 따라 다름
    CUDA 추론 성능 튜닝이 핵심인가? 상황에 따라 가능 검토 우선순위 높음
    운영 직전 서빙 최적화 단계인가? 비교군으로 좋음 주력 후보가 되기 쉬움

    정리하면 이렇습니다. BitsAndBytes는 시작이 빠르고, AWQ는 성능 튜닝의 잠재력이 크다는 쪽에 가깝습니다. 물론 절대적인 법칙은 아닙니다. 하지만 benchmark 관점에서는 이 프레임이 꽤 유용해요. 실제로 써보니까 개발 단계와 운영 단계에서 선택 기준이 달라지더라고요.

    🎉 완료 기준도 명확해야 합니다. 단순히 “모델이 뜬다”가 아니라 아래 항목을 만족해야 진짜 의미 있는 검증이라고 볼 수 있습니다.

    • 동일 조건에서 반복 측정해도 편차가 크지 않을 것
    • VRAM 사용량과 응답 시간이 함께 기록될 것
    • 품질 저하 여부를 최소한 샘플 단위로라도 확인할 것
    • 실제 서비스 패턴과 비슷한 입력 길이를 사용할 것
    LLM 양자화 벤치마크 결과에서 CUDA 성능과 VRAM 비교를 보여주는 대시보드 이미지

    TTFT, token/s, VRAM 사용량을 함께 비교하는 대시보드 형태의 결과 시각화 이미지입니다.

    9. 마무리: 어떤 기준으로 고르면 되나

    이번 글의 핵심은 간단합니다. LLM 양자화는 단순히 메모리 줄이기 게임이 아니라, CUDA 성능과 운영 편의성, 그리고 품질 안정성을 같이 보는 작업이라는 점입니다. 저도 처음엔 BitsAndBytes 하나만 알면 되는 줄 알았는데, 실제로 서비스 비슷하게 붙여보니 AWQ 같은 접근을 같이 봐야 판단이 서더라고요.

    제 기준으로는 이렇습니다. 빠르게 모델을 확인하고 개발 생산성을 높이고 싶다면 BitsAndBytes를 먼저 보시는 게 편합니다. 반대로 LLM 최적화와 추론 성능을 더 밀어붙이고 싶다면 AWQ를 함께 비교해보셔야 합니다. 둘 중 하나가 무조건 정답이라기보다, 어떤 단계에서 무엇을 우선하느냐가 더 중요해요.

    💡 다음 글에서는 vLLM 같은 서빙 런타임과 양자화 조합을 어떻게 비교하면 좋은지 이어서 다뤄볼 예정입니다. 이전 글에서 다뤘던 GPU 메모리 모니터링 방법과 함께 보시면 더 이해가 잘 되실 겁니다.

    BitsAndBytes와 AWQ 중 어떤 LLM 양자화 방식을 고를지 요약한 비교 이미지

    개발 편의성, CUDA 성능, 운영 적합성을 기준으로 두 방식을 고르는 요약 인포그래픽 이미지입니다.

    자주 묻는 질문 정리

    Q1. BitsAndBytes와 AWQ 중 무엇부터 시작하면 좋을까요?

    대부분은 BitsAndBytes부터 시작해도 괜찮습니다. 진입이 쉽고 비교군을 만들기 좋거든요. 그 다음 성능이 아쉬우면 AWQ를 붙여보는 흐름이 현실적입니다.

    Q2. 4bit면 항상 8bit보다 빠른가요?

    아닙니다. 메모리 절감 효과는 크지만, 실제 속도는 CUDA 커널과 런타임 구현에 따라 다릅니다. 그래서 벤치마크가 필요해요.

    Q3. 운영 환경에서는 무엇을 특히 봐야 하나요?

    TTFT, token/s, VRAM 피크, 품질 회귀 여부를 같이 보셔야 합니다. 하나만 좋고 나머지가 흔들리면 운영에서 결국 문제가 생기거든요.

  • [AI] vLLM 배포 시 흔히 겪는 메모리 및 GPU 활용 문제 해결 가이드

    [AI] vLLM 배포 시 흔히 겪는 메모리 및 GPU 활용 문제 해결 가이드

    vLLM 배포 시 흔히 겪는 메모리 및 GPU 활용 문제 해결 가이드

    안녕하세요, 13년차 서버실 지킴이입니다. 요즘 인프라 엔지니어들 사이에서 LLM(Large Language Model) 배포는 뜨거운 감자죠. 저도 홈랩에서 이것저것 시도해보다가 vLLM이라는 라이브러리를 써봤는데, 처음엔 정말 “이거 물건이다!” 싶었거든요. 그런데 막상 모델을 올리고 트래픽을 좀 주니 메모리는 터져나가고, GPU 활용률은 바닥을 기는 경험, 혹시 있으신가요? ⚠️ 제가 직접 겪었던 vLLM 메모리 최적화와 vLLM GPU 활용 문제, 그리고 그 해결 과정을 솔직하게 공유해볼까 합니다. LLM 추론 서버를 운영하시면서 비슷한 vLLM 문제 해결에 어려움을 겪는 분들께 멘토처럼 도움이 되었으면 좋겠습니다.

    vLLM 아키텍처 다이어그램: LLM 추론 서버에서 GPU 메모리, KV 캐시, PagedAttention의 관계

    vLLM이 어떻게 LLM 추론을 최적화하는지, 그리고 왜 메모리와 GPU 활용률 문제가 발생하는지 전체적인 그림을 먼저 보고 가시죠.

    vLLM, 왜 메모리와 GPU를 많이 쓸까요? (핵심 개념 파고들기)

    vLLM은 LLM 추론(Inference) 속도를 획기적으로 개선하기 위해 개발된 오픈소스 라이브러리입니다. 특히 두 가지 핵심 기술 덕분에 주목받고 있는데요.

    • PagedAttention (페이지드 어텐션): 기존 LLM 추론 방식은 각 시퀀스(Sequence)마다 어텐션 키(Key)와 밸류(Value) 캐시(Cache), 일명 KV Cache를 GPU 메모리에 통째로 할당했습니다. 이는 마치 OS의 페이지 메모리 관리와 비슷하게, KV Cache를 페이지(Page) 단위로 쪼개어 필요한 만큼만 동적으로 할당하고 해제하는 방식입니다. 덕분에 메모리 단편화(Fragmentation)를 줄이고, GPU 메모리 사용 효율을 극대화할 수 있죠.
    • Continuous Batching (연속 배치 처리): 일반적인 배치 처리(Batching)는 모든 요청이 완료될 때까지 기다렸다가 다음 배치를 처리합니다. 하지만 Continuous Batching은 GPU가 유휴 상태가 되지 않도록, 새로운 요청이 들어오면 현재 처리 중인 배치에 동적으로 추가하여 계속해서 GPU를 채워 넣는 방식입니다. 이 덕분에 GPU 활용률(Utilization)을 높이고, 전체 처리량(Throughput)을 증가시킬 수 있습니다.

    이렇게 들으면 만능 같죠? 그런데 이 두 가지 기술이 역설적으로 vLLM 메모리 문제와 vLLM GPU 활용 문제를 일으키는 주범이 되기도 합니다. PagedAttention은 KV Cache를 효율적으로 쓰지만, 모델 사이즈가 크고 동시 요청이 많아지면 결국 전체 KV Cache가 커질 수밖에 없어요. Continuous Batching은 GPU를 계속 돌리지만, 모델 로딩 자체에 필요한 메모리도 만만치 않거든요. 제가 처음 이걸 접했을 때 “분명 효율적이라고 했는데, 왜 내 서버는 빌빌거리지?” 싶었답니다. 😅

    vLLM 기본 배포: 첫걸음부터 문제 발생까지

    vLLM을 배포하는 방법은 정말 간단합니다. 저는 주로 Docker를 사용하거나, Python 환경에서 pip으로 설치해서 쓰곤 합니다. 여기서는 Python 코드를 통해 vLLM 서버를 띄우는 기본적인 예시를 보여드릴게요.

    1. vLLM 설치:
      pip install vllm
    2. vLLM 서버 실행 예제:

      간단한 Python 스크립트로 vLLM 서버를 실행하고, 모델을 로딩할 수 있습니다. 저는 주로 Hugging Face Hub에 있는 모델을 사용합니다.

      
      from vllm import LLM, SamplingParams
      
      # LLM 모델 로딩
      # 'meta-llama/Llama-2-7b-chat-hf' 와 같은 모델은 상당한 GPU 메모리를 요구합니다.
      # 실제 운영 환경에서는 더 작은 모델이나 양자화된 모델을 고려해야 합니다.
      llm = LLM(model="TinyLlama/TinyLlama-1.1B-Chat-v1.0", # 예시로 작은 모델 사용
                dtype="auto", # bfloat16, float16 등 GPU에 따라 자동으로 선택
                gpu_memory_utilization=0.9 # 이 설정이 중요합니다! 뒤에서 자세히 다룹니다.
               )
      
      # 프롬프트 리스트
      prompts = [
          "Hello, my name is",
          "The president of the United States is",
          "The capital of France is",
          "What is the meaning of life?",
      ]
      
      # 샘플링 파라미터 설정
      sampling_params = SamplingParams(temperature=0.7, top_p=0.95, max_tokens=128)
      
      # 추론 실행
      outputs = llm.generate(prompts, sampling_params)
      
      # 결과 출력
      for prompt, output in zip(prompts, outputs):
          generated_text = output.outputs[0].text
          print(f"Prompt: {prompt!r}, Generated text: {generated_text!r}")
      
      print("vLLM 서버가 성공적으로 모델을 로딩하고 추론을 수행했습니다.")
              
    vLLM 서버 모델 로딩 및 대기 상태를 보여주는 CLI 화면

    위 코드에서 모델을 로딩하는 <code>LLM(model=…) 부분이 핵심입니다. 이때 지정된 모델이 GPU 메모리에 올라가게 되죠. 처음엔 잘 작동하는 것 같다가도, 여러 요청이 몰리면 문제가 생기기 시작합니다. 바로 이 지점에서 vLLM 메모리 최적화의 필요성을 느끼게 됩니다.

    ⚠️ 제가 겪었던 vLLM 메모리 및 GPU 활용 문제와 해결책

    제가 vLLM을 사용하면서 가장 많이 부딪혔던 문제는 크게 두 가지였습니다. 바로 GPU 메모리 부족(OOM: Out Of Memory)과 기대보다 낮은 GPU 활용률이었죠. 마치 “야근은 했는데 성과가 없는” 상황이랄까요? 삽질 좀 했습니다 ㅎㅎ. 하나씩 해결 과정을 공유해 드릴게요.

    1. GPU 메모리 부족 (OOM) 문제 해결

    가장 흔한 문제입니다. 모델을 로딩하자마자 OOM이 나거나, 몇몇 요청 처리 후 서버가 죽는 경우죠. 이건 주로 KV Cache 때문에 발생합니다.

    1. gpu_memory_utilization 옵션 조절 (가장 중요!)

      이 옵션은 vLLM이 GPU 메모리 중 KV Cache에 할당할 최대 비율을 지정합니다. 기본값은 0.9 (90%)인데, 이 값을 잘 조절해야 합니다. 모델 자체를 로딩하는 데 필요한 메모리도 있기 때문에, 너무 높으면 OOM이 날 수 있어요. 그렇다고 너무 낮추면 KV Cache 공간이 부족해져서 동시 처리량이 줄어듭니다.

      
      llm = LLM(model="TinyLlama/TinyLlama-1.1B-Chat-v1.0",
                gpu_memory_utilization=0.7 # 70%로 낮춰서 시도해보세요.
               )
              

      💡 팁: GPU 메모리가 넉넉하다면 0.8~0.9까지도 괜찮지만, 10GB 미만 GPU라면 0.6~0.7부터 시작해서 조금씩 올려보는 걸 추천합니다. vLLM 메모리 최적화의 핵심입니다.

    2. 모델 양자화 (Quantization) 적용

      모델 자체의 크기를 줄이는 가장 효과적인 방법입니다. LLM 양자화는 모델의 가중치(Weights)를 낮은 비트(예: FP32 → FP16 → INT8 → INT4)로 표현하여 메모리 사용량을 줄입니다. 정확도는 조금 희생될 수 있지만, 체감 성능 저하 없이 메모리를 크게 절약할 수 있어요.

      
      llm = LLM(model="TheBloke/Llama-2-7B-Chat-GPTQ", # GPTQ 양자화된 모델 사용 예시
                dtype="auto" # 양자화된 모델은 dtype을 auto로 두는 것이 일반적
               )
              

      저도 홈랩에서 8비트(INT8)나 4비트(INT4) 양자화(Quantization)된 모델을 많이 사용하는데, 특히 NVIDIA RTX 30/40 시리즈 같은 컨슈머 GPU에서는 필수적이라고 느껴지더라고요.

    3. 더 작은 모델 선택

      물론 가장 확실한 방법입니다. Llama-2-70B 같은 거대 모델 대신, Llama-2-7B나 TinyLlama 같은 작은 모델을 사용하는 거죠. 요구사항에 맞춰 모델을 선택하는 것이 LLM 추론 서버 운영의 기본입니다.

    4. max_model_len 및 max_num_seqs 조절

      max_model_len은 모델이 처리할 수 있는 최대 시퀀스 길이(토큰 수)를 의미하고, max_num_seqs는 한 번에 처리할 수 있는 최대 동시 시퀀스 수를 의미합니다. 이 값들이 너무 크면 KV Cache가 그만큼 더 많이 필요해집니다. 특히 max_model_len은 프롬프트 길이와 생성될 답변 길이를 모두 고려해야 해서 중요합니다.

      
      llm = LLM(model="TinyLlama/TinyLlama-1.1B-Chat-v1.0",
                max_model_len=1024, # 최대 토큰 길이 조절
                max_num_seqs=256 # 최대 동시 시퀀스 수 조절
               )
              
    5. 메모리 스와핑 활용

      vLLM은 KV Cache가 GPU 메모리에 부족할 경우 CPU 메모리로 스와핑하는 기능을 제공합니다. 이는 OOM을 방지하는 최후의 보루가 될 수 있지만, 성능 저하가 발생할 수 있으니 주의해야 합니다. 최신 vLLM 버전의 경우 PagedAttention 자체가 이를 효율적으로 처리하므로, 명시적 설정이 필요한 경우는 많지 않습니다.

      
      llm = LLM(model="TinyLlama/TinyLlama-1.1B-Chat-v1.0",
                # 최신 vLLM에서는 PagedAttention 자체가 메모리 관리를 효율적으로 처리합니다
                # 필요시 vLLM 버전에 따라 추가 옵션이 사용 가능할 수 있습니다
               )
              

      사실 vLLM의 PagedAttention은 이미 OS의 가상 메모리 관리처럼 동작해서, 필요한 경우 자동으로 메모리를 활용하는 경향이 있습니다. 버전에 따라 세부 옵션이 달라질 수 있으므로, 공식 문서를 참고하는 것이 좋습니다.

    2. 낮은 GPU 활용률 문제 해결

    Continuous Batching 덕분에 GPU 활용률이 높아야 하는데, 생각보다 낮게 나오는 경우가 있습니다. 이건 주로 요청이 너무 적거나, 모델의 추론 속도가 너무 빨라서 GPU가 대기하는 시간이 길어질 때 발생합니다.

    1. 동시 요청 늘리기

      가장 직접적인 방법입니다. vLLM은 Continuous Batching을 통해 동시 요청이 많을수록 GPU를 더 효율적으로 사용합니다. 테스트 환경이라면 벤치마크 툴로 요청 수를 늘려보세요.

    2. 배치 사이즈 최적화

      위에서 언급한 max_num_seqs와 max_model_len 값을 적절히 조절하는 것이 중요합니다. 너무 작으면 GPU가 놀고, 너무 크면 OOM이 나거나 지연 시간(Latency)이 길어질 수 있습니다. 여러 번 테스트하면서 최적값을 찾아야 합니다. “삽질 좀 해봐야 내 것이 되는 법”이거든요. 😉

    3. 모델 로딩 시 enforce_eager=True 옵션 (디버깅 목적)

      이 옵션은 디버깅 목적으로 GPU 활용률을 강제로 높여볼 때 사용해볼 수 있습니다. 하지만 실제 운영 환경에서는 오버헤드가 발생할 수 있으니 주의해야 합니다.

      
      llm = LLM(model="TinyLlama/TinyLlama-1.1B-Chat-v1.0",
                enforce_eager=True # 디버깅 및 테스트 목적으로 사용
               )
              

      이 옵션은 주로 vLLM 내부 동작을 확인하거나 특정 문제를 디버깅할 때 유용하고, 일반적인 LLM 추론 서버 운영에는 권장되지 않습니다.

    nvidia-smi를 통해 확인한 vLLM GPU 메모리 및 활용률 개선 결과 스크린샷

    이렇게 여러 옵션을 조절해가면서 저의 홈랩 GPU 자원을 최대한 쥐어짜내는 데 성공했습니다! 물론 처음부터 완벽하게 되는 건 아니었고, nvidia-smi 명령어를 옆에 띄워놓고 메모리 사용량과 활용률을 실시간으로 보면서 값들을 바꿔가며 테스트했죠. vLLM 트러블슈팅은 인내심과의 싸움이더라고요.

    ✅ vLLM 최적화, 제대로 됐는지 확인해볼까요?

    이제 vLLM 메모리 최적화와 vLLM GPU 활용 개선을 위한 여러 방법을 적용해봤으니, 실제로 효과가 있는지 확인해봐야겠죠? 저는 주로 nvidia-smi와 vLLM의 로그 메시지를 활용해서 검증합니다.

    1. nvidia-smi로 GPU 상태 확인

      터미널에서 nvidia-smi를 주기적으로 실행하거나, watch -n 1 nvidia-smi 명령으로 1초마다 갱신되는 정보를 확인해보세요. 최적화 전후로 다음과 같은 변화를 볼 수 있을 겁니다.

      • GPU Memory Usage (메모리 사용량): OOM이 발생하지 않고, 설정한 gpu_memory_utilization에 맞춰 안정적으로 유지되는지 확인합니다.
      • GPU Utilization (GPU 활용률): 요청이 들어올 때 GPU-Util이 70~90% 이상으로 높게 유지되는지 확인합니다. 만약 낮다면 동시 요청 수를 늘리거나 배치 사이즈를 조절해봐야 합니다.
      
      watch -n 1 nvidia-smi
              

      저도 처음엔 GPU-Util이 20~30%에서 빌빌거리다가, 설정을 만지고 나니 80% 이상으로 치솟는 것을 보고 “드디어 됐다!” 하고 환호성을 질렀답니다. 🎉

    2. vLLM 로그 확인

      vLLM은 시작 시 모델 로딩 정보, KV Cache 할당 정보 등을 상세하게 로그로 남겨줍니다. 이 로그를 통해 내 설정이 제대로 적용되었는지 확인할 수 있습니다.

      • vLLM engine is initialized with model: ...
      • GPU memory usage: X.X GB (Y.Y%) for model weights, Z.Z GB for KV cache.

      특히 KV Cache에 할당된 메모리 양이 gpu_memory_utilization과 잘 맞아떨어지는지 확인하는 것이 중요합니다.

    3. 부하 테스트 (Load Testing)

      실제 서비스 환경을 시뮬레이션하기 위해 부하 테스트 툴(예: `locust`, `wrk`)을 사용해서 동시 요청을 발생시켜보고, 이때의 GPU 상태와 추론 지연 시간(Latency), 처리량(Throughput)을 측정해보는 것이 가장 확실한 방법입니다.

    마무리하며: 삽질은 경험이 되고, 경험은 노하우가 됩니다.

    오늘은 vLLM 배포 시 흔히 겪는 메모리 및 GPU 활용 문제 해결 가이드를 저의 경험을 토대로 이야기해봤습니다. 돌이켜보면 인프라 엔지니어의 삶은 끊임없는 트러블슈팅(Troubleshooting)의 연속인 것 같아요. 특히 LLM 추론 서버 같은 새로운 기술 스택을 다룰 때는 더욱 그렇죠.

    결론적으로 vLLM 최적화의 핵심은 다음과 같습니다.

    • gpu_memory_utilization 값을 적절히 조절하여 KV Cache 메모리를 효율적으로 관리하는 것.
    • 양자화(Quantization)된 모델을 활용하여 모델 자체의 메모리 점유율을 낮추는 것.
    • max_model_len과 max_num_seqs를 서비스 요구사항과 GPU 자원에 맞춰 최적화하는 것.
    • 끊임없이 nvidia-smi로 모니터링하며 실험하고, 최적의 파라미터를 찾아내는 인내심!
    vLLM 메모리 및 GPU 활용 최적화 핵심 팁 요약 인포그래픽

    제가 처음 vLLM을 만졌을 때 “이거 왜 이래?” 하면서 답답해했던 기억이 생생하네요. 하지만 이런 삽질 경험 하나하나가 쌓여서 결국 저만의 vLLM 트러블슈팅 노하우가 되더라고요. 혹시 더 궁금한 점이나 다른 vLLM 문제 해결 팁이 있다면 댓글로 알려주세요. 다음번에는 더 깊이 있는 LLM 배포 팁, 예를 들어 멀티 GPU 환경이나 분산 추론에 대해 다뤄볼 수도 있겠네요. 그때까지 여러분의 서버실도 평안하시기를 바랍니다!