|
|
vLLM
LLM을 serving하기 위한 추론 엔진이다.
모델을 만드는 도구가 아니라, 이미 만들어진 모델을 속도 개선 및 효율성을 더해
GPU에서 돌려서 응답을 뱉어주는 소프트웨어다.
우선 LLM이 왜 느린지부터 알아야한다.
LLM이 텍스트를 생성하는 방식은 autoregressive(자기회귀)를 사용하는데
이는 토큰을 하나 만들고, 그 토큰을 다시 입력에 붙여서 다음 토큰을 만드는 걸 반복하는 형태이다.
이 과정에서 입력 프롬프트 전체를 한 번에 행렬 곱셈으로 처리한다고 한다. (Prefill)
이렇게 되면 GPU 연산량이 많아져서 compute-bound가 일어난다.
또한 토큰 하나를 만들 때마다 전체 모델 가중치를 한 번씩 다 읽는다. (Decode)
연산량 자체는 작은데 메모리에서 가중치를 읽어오는 시간이 길어져서 memory-bound가 일어난다.
따라서 vLLM에서는 PagedAttention이라는 알고리즘을 사용한다.
https://arxiv.org/pdf/2309.06180
기존에는 토큰을 생성할 때마다 지금까지 계산한 attention key/value값을 KV 캐시라는 형태로 GPU 메모리에 쌓아둔다고 한다.
문제점은 최대 몇 토큰까지 갈지 모르니, 미리 최대 길이만큼 메모리를 사용하려고 잡아놓는다고 한다.
이렇게 되면 실제로 짧게 끝나는 요청일지라도 미리 잡아놓으니 메모리 낭비가 일어난다.
PagedAttention은 KV 캐시를 page 단위(고정된 크기)로 쪼갠다.
실제로 쓰는 만큼만 페이지를 할당하고, 다 쓰면 반납한다. 따라서 메모리 낭비가 거의 없고
같은 GPU를 사용해도 많은 요청들을 동시에 처리 가능하다.
(운영체제의 페이징 기법을 VRAM에 도입했다.)
또한 Continuous Batching이라는 배치 전략을 사용한다.
기존에는 Satic Batching을 사용했는데 문제점은 요청들을 한 배치로 묶어서 처리하는데
이 배치 안에서 제일 긴 요청이 끝날 때까지 나머지 요청들도 기다린다는 비효율이 있었다.
vLLM의 Continuous Batching은 요청을 묶지 않고 요청이 하나 끝나면 그 자리에 새 요청을 투입하는 형태이다.
배치 구성이 토큰 생성 한 번마다 동적으로 바뀌어서 GPU가 노는 시간이 최소화된다고 한다.
즉, vLLM은 모델을 어떻게 실행할지를 스케줄링하고 최적화하는 방법이다.
모델 자체를 경량화/양자화 시키는 방법이 아니다.
llmcompressor
모델의 가중치를 양자화하는 도구다.
원래 모델 가중치는 FP16으로 저장되어 있다. 이걸 INT4로 바꾸게 된다면
가중치 용량이 대략 1/4로 줄고(VRAM 절약), 메모리에서 읽어와야 할 데이터양이 줄어서 속도도 빨라질 수 있다.
https://docs.vllm.ai/projects/llm-compressor/en/latest/
Test에 사용할 알고리즘은 llmcompressor의 GPTQ이다.
GPTQ(Generative Pre-trained Transformer Quantization)는 2차 근사 오차 보정을 쓴다.
이를 선택한 이유는 AutoAWQ를 먼저 진행했었는데 공식적으로 deprecated 되어 있었고,
GPTQ가 vLLM과 제일 안정적으로 호환되기 때문에 선택했다.
핵심 아이디어로는 가중치의 행렬 하나씩 순서대로 양자화를 시키지만,
4비트로 깎으면 나오는 원래 값과 양자화 된 값의 오차를 계산하여 아직 양자화 안 시킨
나머지 가중치에 이 오차를 분산시켜서 보정한다.
vLLM과 연동이 잘 되는 이유는 같은 회사에서 만들었기 때문이다...
llmcompressor가 이 파일을 vLLM이 어떻게 읽어야 하는지까지 염두에 두고 저장 포맷을 설계했고,
vLLM은 이 포맷이면 어떤 고속 커널을 골라야 하는지를 이미 알고 있도록 만들어져 있기 때문이다.
기존에는 각 도구가 독립적으로 관리되다 보니 서로 다른 속도로 업데이트가 되었기 때문에 파편화가 일어났다.
현재는 compressed-tensors 라이브러리를 vLLM/llmcompressor에서 제공하여
압축 알고리즘이 뭐든 상관없이 결과물을 표준화시켜 저장해준다고 한다.
Test
기존 추론
vLLM
(EngineCore pid=61336) INFO 07-07 17:13:43 [gpu_worker.py:508] Available KV cache memory: 0.17 GiB
(EngineCore pid=61336) INFO 07-07 17:13:16 [gpu_model_runner.py:5255] Model loading took 7.16 GiB memory and 1.235461 seconds
vLLM + llmcompressor
(EngineCore pid=60698) INFO 07-07 14:11:43 [gpu_worker.py:508] Available KV cache memory: 3.94 GiB
(EngineCore pid=60698) INFO 07-07 14:11:33 [gpu_model_runner.py:5255] Model loading took 3.31 GiB memory and 1.601756 seconds
VRAM 측정을 못하는 이유는 vLLM이 별도 프로세스에서 모델을 돌리기 때문이다.
그대신 vLLM에서 모델을 돌리게 되면 로그가 떠서 이를 확인하면 된다.
Available KV cache memory: 가중치가 작아진 만큼 KV 캐시 여유가 늘었다는 걸 보여주는 지표다.
Jetson AGX Orin에서의 평가
(EngineCore_DP0 pid=26325) INFO 07-08 18:32:05 [gpu_model_runner.py:4338] Model loading took 3.31 GiB memory and 2.139432 seconds
(EngineCore_DP0 pid=26325) INFO 07-08 18:34:09 [gpu_worker.py:424] Available KV cache memory: 38.11 GiB
(EngineCore_DP0 pid=28547) INFO 07-09 10:13:20 [gpu_model_runner.py:4338] Model loading took 7.16 GiB memory and 6.279727 seconds
(EngineCore_DP0 pid=28547) INFO 07-09 10:14:07 [gpu_worker.py:424] Available KV cache memory: 33.49 GiB
code
우선 기존에 Fine-Tuning을 하면 아래와 같이 가중치가 분할되어 나오게 되는데
이를 병합(merge)해줄 코드가 필요하다.
merge.py
| from peft import PeftModel from transformers import Qwen2_5_VLForConditionalGeneration, AutoProcessor base_model_path = "Qwen/Qwen2.5-VL-3B-Instruct" lora_path = "../../fine-tune/Qwen-VL-Series-Finetune/output/qwen25vl-airlab-lora" merged_path = "./qwen2.5-vl-merged" model = Qwen2_5_VLForConditionalGeneration.from_pretrained( base_model_path, torch_dtype="auto", device_map="cpu" ) model = PeftModel.from_pretrained(model, lora_path) model = model.merge_and_unload() model.save_pretrained(merged_path) processor = AutoProcessor.from_pretrained(base_model_path) processor.save_pretrained(merged_path) |
모델을 병합하고 나면, GPTQ로 4비트 양자화한다. (llmcompressor)
| """ conda create -n vllm_qwen25vl python=3.10 -y conda activate vllm_qwen25vl pip install vllm pip install llmcompressor """ import json import re from transformers import Qwen2_5_VLForConditionalGeneration, AutoProcessor from llmcompressor import oneshot from llmcompressor.modifiers.quantization import GPTQModifier from datasets import Dataset model_path = "./qwen2.5-vl-merged" model = Qwen2_5_VLForConditionalGeneration.from_pretrained( model_path, torch_dtype="auto", device_map="auto", ) processor = AutoProcessor.from_pretrained(model_path) NUM_CALIBRATION_SAMPLES = 256 MAX_SEQ_LENGTH = 2048 with open("/home/airlab/fine-tune/Dataset/result/train_qwen_airlab.json", "r", encoding="utf-8") as f: raw_data = json.load(f) def extract_text(example): parts = [] for turn in example["conversations"]: text = turn["value"] text = re.sub(r"<image>\n?", "", text) role = "user" if turn["from"] == "human" else "assistant" parts.append(f"{role}: {text}") return {"text": "\n".join(parts)} processed = [extract_text(ex) for ex in raw_data] ds = Dataset.from_list(processed) ds = ds.shuffle(seed=42).select(range(min(NUM_CALIBRATION_SAMPLES, len(ds)))) def tokenize(example): return processor.tokenizer( example["text"], padding=False, max_length=MAX_SEQ_LENGTH, truncation=True, ) ds = ds.map(tokenize, remove_columns=ds.column_names) recipe = GPTQModifier( targets="Linear", scheme="W4A16", ignore=["lm_head", "re:.*visual.*"], ) oneshot( model=model, recipe=recipe, dataset=ds, max_seq_length=MAX_SEQ_LENGTH, num_calibration_samples=NUM_CALIBRATION_SAMPLES, output_dir="./qwen2.5-vl-vllm", ) |
캘리브레이션이 필요한 이유
GPTQ가 양자화를 할 때 각 가중치를 어떻게 4비트로 깎을지 결정하려면,
그 가중치가 실제로 어떤 값들과 곱해지는지를 알아야 하기 때문이다.
실제 fine-tuning에 썼던 데이터를 모델에 흘려보내면서, 각 레이어에 실제로 어떤 입력값이 들어오는지를 수집한다
이 통계를 알면 가중치마다 얼마나 영향력이 큰지를 고려해서 양자화하기가 가능해진다.
Test code
https://github.com/mxnseo/Qwen2.5-VL-ko
/vLLM_Qwen2.5-VL
| # huggingface - Qwen2.5-VL-3B-Instruct // Get Started # https://huggingface.co/Qwen/Qwen2.5-VL-3B-Instruct # git - mxnseo # conda env """ -- RTX 4070 PC -- conda create -n qwen25vl python=3.10 -y conda activate qwen25vl pip install torch==2.5.1 torchvision==0.20.1 --index-url https://download.pytorch.org/whl/cu121 # transformers (pip version -> qwen2_5_vl KeyError) pip install git+https://github.com/huggingface/transformers accelerate # Qwen VL util pip install qwen-vl-utils[decord]==0.0.8 # if fine-tuning model load pip install peft -- Jetson AGX Orin -- conda create -n qwen25vl python=3.10 -y conda activate qwen25vl pip install torch-2.5.0-cp310-cp310-linux_aarch64.whl pip install torchvision-0.20.0-cp310-cp310-linux_aarch64.whl # transformers (pip version -> qwen2_5_vl KeyError) pip install git+https://github.com/huggingface/transformers accelerate # Qwen VL util pip install qwen-vl-utils==0.0.8 pip install "numpy<2" """ from transformers import AutoProcessor, Qwen2_5_VLForConditionalGeneration import torch import time # Inference Time Check def measure_inference(func): def wrapper(*args, **kwargs): if torch.cuda.is_available(): torch.cuda.synchronize() t_start = time.perf_counter() result = func(*args, **kwargs) if torch.cuda.is_available(): torch.cuda.synchronize() t_end = time.perf_counter() elapsed = t_end - t_start print(f"\n[{func.__name__}] Inference Time: {elapsed:.3f} s") if torch.cuda.is_available(): print(f"VRAM: {torch.cuda.memory_allocated() / 1e9:.2f} GB") # if result == token -> token/s time if isinstance(result, tuple) and len(result) == 2: actual_result, n_tokens = result if isinstance(n_tokens, int): print(f"Tokens: {n_tokens} | {n_tokens / elapsed:.1f} tok/s") return actual_result return result return wrapper # model_path = "Qwen/Qwen2.5-VL-3B-Instruct" # processor = AutoProcessor.from_pretrained(model_path) # model = Qwen2_5_VLForConditionalGeneration.from_pretrained( # model_path, # torch_dtype=torch.bfloat16, # # attn_implementation="flash_attention_2", # attn_implementation="sdpa", # device_map="auto" # ) # fine-tuning model load # from peft import PeftModel # import os # BASE_MODEL = "Qwen/Qwen2.5-VL-3B-Instruct" # LORA_PATH = "/home/airlab/fine-tune/Qwen-VL-Series-Finetune/output/qwen25vl-airlab-lora/checkpoint-38" # processor = AutoProcessor.from_pretrained(LORA_PATH) # model = Qwen2_5_VLForConditionalGeneration.from_pretrained( # BASE_MODEL, # torch_dtype=torch.bfloat16, # attn_implementation="sdpa", # device_map="auto" # ) # model = PeftModel.from_pretrained(model, LORA_PATH) # non_lora_path = os.path.join(LORA_PATH, "non_lora_state_dict.bin") # if os.path.exists(non_lora_path): # non_lora = torch.load(non_lora_path, map_location="cpu") # model.load_state_dict(non_lora, strict=False) # model.eval() # vllm model load import base64 from vllm import LLM, SamplingParams QUANT_MODEL_PATH = "./vLLM_Qwen2.5-VL/qwen2.5-vl-vllm" llm = None # only vLLM # MERGED_MODEL_PATH = "./vLLM_Qwen2.5-VL/qwen2.5-vl-merged" sampling_params = SamplingParams(temperature=0.0, max_tokens=64) def to_image_url(source: str) -> str: if source.startswith("http://") or source.startswith("https://"): return source with open(source, "rb") as f: b64 = base64.b64encode(f.read()).decode("utf-8") return f"data:image/jpeg;base64,{b64}" # --- # Simple Inference (eng) @measure_inference def simple_inference(): messages = [ { "role": "system", "content": "Answer only used english" }, { "role": "user", "content": [ {"type": "image", "url": "https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/bee.jpg"}, {"type": "text", "text": "Can you describe this image?"}, ] }, ] inputs = processor.apply_chat_template( messages, add_generation_prompt=True, tokenize=True, return_dict=True, return_tensors="pt", ).to(model.device, dtype=torch.bfloat16) generated_ids = model.generate(**inputs, do_sample=False, max_new_tokens=64) input_len = inputs.input_ids.shape[-1] n_tokens = generated_ids.shape[-1] - input_len generated_ids_trimmed = [ out[len(in_):] for in_, out in zip(inputs.input_ids, generated_ids) ] generated_texts = processor.batch_decode( generated_ids_trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False, ) print(generated_texts[0]) return generated_ids[0], n_tokens # Simple Inference (ko) @measure_inference def korean_inference(): messages = [ { "role": "user", "content": [ {"type": "image_url", "image_url": {"url": to_image_url("test_c.png")}}, {"type": "text", "text": "사진 속 장소는 어디인가요?"}, ] }, ] # inputs = processor.apply_chat_template( # messages, # add_generation_prompt=True, # tokenize=True, # return_dict=True, # return_tensors="pt", # ).to(model.device, dtype=torch.bfloat16) # generated_ids = model.generate(**inputs, do_sample=False, max_new_tokens=64) # input_len = inputs.input_ids.shape[-1] # n_tokens = generated_ids.shape[-1] - input_len # generated_ids_trimmed = [ # out[len(in_):] for in_, out in zip(inputs.input_ids, generated_ids) # ] # generated_texts = processor.batch_decode( # generated_ids_trimmed, # skip_special_tokens=True, # clean_up_tokenization_spaces=False, # ) # print(generated_texts[0]) # return generated_ids[0], n_tokens outputs = llm.chat(messages, sampling_params=sampling_params) generated_text = outputs[0].outputs[0].text n_tokens = len(outputs[0].outputs[0].token_ids) print(generated_text) return generated_text, n_tokens # Object recognition (ko, for project) @measure_inference def object_recognition(): messages = [ { "role": "user", "content": [ {"type": "image", "path": "hard.jpg"}, {"type": "text", "text": "이게 뭔가요? 물건 이름과 어떤 상황인가요?"}, ] }, ] inputs = processor.apply_chat_template( messages, add_generation_prompt=True, tokenize=True, return_dict=True, return_tensors="pt", ).to(model.device, dtype=torch.bfloat16) generated_ids = model.generate(**inputs, do_sample=False, max_new_tokens=64) input_len = inputs.input_ids.shape[-1] n_tokens = generated_ids.shape[-1] - input_len generated_ids_trimmed = [ out[len(in_):] for in_, out in zip(inputs.input_ids, generated_ids) ] generated_texts = processor.batch_decode( generated_ids_trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False, ) print(generated_texts[0]) return generated_ids[0], n_tokens # Multi-image Inference (ko) @measure_inference def multi_image_inference(): messages = [ { "role": "user", "content": [ {"type": "text", "text": "두 이미지의 공통점은 무엇인가요?"}, {"type": "image", "url": "https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/bee.jpg"}, {"type": "image", "url": "https://huggingface.co/datasets/huggingface/documentation-images/resolve/0052a70beed5bf71b92610a43a52df6d286cd5f3/diffusers/rabbit.jpg"}, ] }, ] inputs = processor.apply_chat_template( messages, add_generation_prompt=True, tokenize=True, return_dict=True, return_tensors="pt", ).to(model.device, dtype=torch.bfloat16) generated_ids = model.generate(**inputs, do_sample=False, max_new_tokens=64) input_len = inputs.input_ids.shape[-1] n_tokens = generated_ids.shape[-1] - input_len generated_ids_trimmed = [ out[len(in_):] for in_, out in zip(inputs.input_ids, generated_ids) ] generated_texts = processor.batch_decode( generated_ids_trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False, ) print(generated_texts[0]) return generated_ids[0], n_tokens if __name__ == "__main__": # vLLM + llmcompressor llm = LLM( model=QUANT_MODEL_PATH, quantization="compressed-tensors", max_model_len=4096, gpu_memory_utilization=0.85, limit_mm_per_prompt={"image": 2} ) # llm = LLM( # model=MERGED_MODEL_PATH, # max_model_len=4096, # gpu_memory_utilization=0.85, # limit_mm_per_prompt={"image": 2} # ) # simple_inference() korean_inference() korean_inference() # object_recognition() # multi_image_inference() |

첫댓글 젯슨보드에서 평가할것
추가했습니다
성능비교 표로 정리할것