다이내믹 제너레이터 (Dynamic Generator)

다이내믹 제너레이터 (Dynamic Generator)

ExLlamaV2 v0.1.0부터 도입된 다이내믹 제너레이터는, paged attention 기반의 연속 배칭·스마트 프롬프트 캐싱·K/V 캐시 중복 제거를 하나의 API로 합친 추론 인터페이스예요. 이전에는 '스트리밍·베이스' 두 제너레이터가 갖고 있던 추론·샘플링·추측 디코딩 기능을 전부 흡수하면서도, FP8 캐시만큼은 제외하고(Q4가 모든 면에서 더 나아서) API를 크게 단순화했어요. 이 문서는 그 원리와 사용법을 설명해요.

이 문서가 쓰인 시점 기준 ExLlamaV2 최신 릴리스는 v0.1.4였어요.

왜 배칭이 까다로운가: 정적 배칭의 한계

길이가 서로 다른 여러 시퀀스를 하나의 K/V 캐시에 담는 방법은 여럿 있는데, 대부분 단점이 있어요.

  • 왼쪽 패딩(left-padding): ExLlama의 '베이스·스트리밍' 제너레이터가 쓰는 방식이에요. 모든 시퀀스를 오른쪽으로 정렬해서 배치 전체의 마지막 토큰 logit을 한 번에 얻지만, 패딩이 공간을 낭비하고 Flash Attention은 왼쪽 패딩을 지원하지 않아요.
  • 오른쪽 패딩(right-padding): 어텐션 마스크가 불필요한 대신, 모든 시퀀스 길이가 같아질 때까지 가장 짧은 시퀀스부터 시작해야 해서 초기 배치 크기가 낮아지고, 역시 패딩 공간이 낭비돼요.
  • Flash Attention의 언패딩 캐시: 언패딩을 지원하긴 하지만 K/V 입력에 빈틈을 허용하지 않아, 추론보다 학습에 더 맞는 방식이에요.

연속 배칭 (Continuous Batching)

Flash Attention 2.5.7부터 paged attention을 지원하면서 상황이 바뀌었어요. 캐시를 블록 테이블로 인덱싱하니까, 원하는 대로 모양을 잡을 수 있어 시퀀스가 자랄 공간을 남겨두면서도 패딩에 의존하지 않아요. 낭비되는 공간은 시퀀스를 페이지 경계에 맞추는 정도뿐이에요 (페이지 크기는 현재 256 토큰 고정).

paged attention의 핵심은, 배치에서 시퀀스를 더하거나 뺄 때 블록 인덱스만 갱신하면 되고 캐시 전체를 리셰이프할 필요가 없다는 점이에요. 제너레이터는 잡 큐(job queue)를 돌려서, 처음엔 캐시에 들어갈 만큼 잡을 시작하고, 하나가 끝나면 그 페이지들을 해제해 큐의 다음 잡(원래 큐에 있던 것이든 중간에 추가된 것이든)이 들어올 공간을 만들어 줘요.

페이지 해제는 자연히 캐시에 빈틈을 남기는데, paged attention은 블록 인덱스가 연속일 필요가 없으니 다음에 활성화되는 잡이 그 빈틈을 그대로 채울 수 있어요. 캐시는 조각 모음(defragment)도 가능한데, 부드러운 동작을 위해 제너레이터는 큐가 비었을 때만 참조되지 않은 페이지들을 조각 모음해요.

중복 제거 (Deduplication)

블록 테이블의 또 다른 장점은, 여러 시퀀스가 같은 페이지를 인덱스할 수 있다는 거예요. LLM 추론에서 아주 흔한 상황이 여러 프롬프트가 긴 공통 프리픽스(예: 긴 시스템 프롬프트)를 공유하는 경우인데, 이럴 때 새 잡을 활성화할 때 캐시에 이미 있는 K/V를 참조·재사용해서 prefill 시간과 VRAM을 아낄 수 있어요.

긴 프롬프트를 공유하되 끝만 조금씩 다른 여러 잡을 시작하는 경우를 떠올려 보면 돼요. 대표 예시가 저장소의 inference_dedup.py인데, 인스트럭트 모델에 짧은 이야기를 읽히고 서로 다른 질문을 여러 개 던지는 상황이에요. 각 프롬프트가 이야기 전체 + 고유 질문을 담는데, 제너레이터는 이를 자동으로 '이야기 한 번 + 질문들' 꼴로 줄여요. 참조한 예시는 모든 잡을 한 번에 못 담는 짧은 캐시를 쓰는데, 그래도 문제없어요 — 잡들이 끝나 공간이 나면 새로 활성화된 잡이 진행 중인 잡의 페이지를 여전히 참조할 수 있거든요.

이 중복 제거 덕분에 배칭 API도 훨씬 단순해졌어요. 하드웨어(VRAM 크기 등)에 맞춰 제너레이터를 한 번 설정해 두면, 이 기능을 직접 관리할 필요가 없어요.

# Generate a story
story = generator.generate(
    prompt = "Once upon a time",
    max_new_tokens = 1000,
)

# Generate 100 stories
stories = generator.generate(
    prompt = ["Once upon a time"] * 100,
    max_new_tokens = 1000,
)

후자의 배치는 캐시가 충분히 크면 배치 크기 100으로 돌 수도 있고, 수용 가능한 만큼만(예: 17) 유지될 수도 있어요. 어느 쪽이든 요청한 100개의 완성본을 돌려줘요. 저장소의 MMLUHumanEval 스크립트는 이 방식으로 수천 개의 잡을 큐에 넣고, 각 잡이 끝날 때마다 출력을 모으는 루프만 돌려요.

프롬프트 캐싱 (Prompt caching)

재사용은 현재 활성 잡에만 한정되지 않아요. 제너레이터는 가장 최근에 쓰인 페이지를 덮어쓰지 않으려 하기 때문에, 다음 잡이 최근에 끝난 잡들 중 하나와 프리픽스를 공유한다면 캐시된 데이터를 계속 재사용할 수 있어요.

  • 스트리밍 제너레이터가 이미 갖고 있던 장점: 챗봇에서 과거를 편집하지 않는 한, 매 턴 사용자의 최신 프롬프트까지의 전체 컨텍스트를 캐시로 재사용할 수 있어요.
  • 다이내믹 제너레이터의 확장: 캐시에 공간이 있는만큼 여러 과거 컨텍스트를 동시에 보관할 수 있어요. 두 컨텍스트를 오가며 대화해도, 캐시가 충분히 크면 하나를 잊지 않아도 돼요.

사용법: 모델과 제너레이터 준비

Llama3-8B를 64k 토큰 캐시로 로드하는 기본부터 볼게요.

from exllamav2 import ExLlamaV2, ExLlamaV2Config, ExLlamaV2Cache, ExLlamaV2Tokenizer

model_dir = "/mnt/str/models/llama3-8b-exl2/4.0bpw/"
config = ExLlamaV2Config(model_dir)
model = ExLlamaV2(config)
cache = ExLlamaV2Cache(model, max_seq_len = 65536, lazy = True)
model.load_autosplit(cache, progress = True)
tokenizer = ExLlamaV2Tokenizer(config)

평소와 같은 절차인데, 주의할 점은 모델·캐시가 배치 크기 없이(즉 암묵적으로 배치 1로) 초기화된다는 거예요. 그래도 다이내믹 제너레이터는 배칭을 허용해요.

모델의 기본 최대 시퀀스 길이가 여기선 8k인데, 이건 어디까지나 하나의 시퀀스 최대 길이예요. config.max_seq_lenconfig.scale_alpha_value 등을 로드 전에 고치면 늘릴 수 있어요. 어쨌든 64k 캐시라면 8k짜리 시퀀스 8개 or 1k짜리 시퀀스 64개를 담을 수 있어요.

Q4 캐시 모드를 쓰려면 ExLlamaV2Cache 대신 ExLlamaV2Cache_Q4를 쓰면 돼요. FP8 캐시는 다이내믹 제너레이터에 적용되지 않았는데, Q4가 모든 면에서 성능이 더 좋기 때문이에요. 대신 v0.1.5에서 FP8과 비슷한 용량이면서 더 정확한 Q8 모드가 추가될 예정이고, Q3·Q6 모드도 계획에 있어요.

이제 제너레이터를 만들게요.

from exllamav2.generator import ExLlamaV2DynamicGenerator

generator = ExLlamaV2DynamicGenerator(
    model = model,
    cache = cache,
    tokenizer = tokenizer,
)

여기 추측 디코딩, 최대 허용 배치 크기 등 설정 옵션이 여럿 있지만 문서스트링(docstring)을 참고하면 돼요. 기본값으로 단일 생성부터 해볼게요.

output = generator.generate(
    prompt = "Five good reasons to adopt a cat:",
    max_new_tokens = 200,
    add_bos = True,
)

print(output)

generate의 선택 인자 중 중요한 것 몇 가지:

  • encode_special_tokens: 프롬프트의 특수 토큰을 텍스트가 아니라 토큰으로 인코딩하고 싶을 때 켜요
  • decode_special_tokens: 기본적으로 출력의 특수 토큰은 디코딩되지 않고 빈 문자열이 돼요
  • stop_conditions: max_new_tokens에 도달하기 전에 시퀀스를 끝낼 토큰 ID/문자열 목록
  • gen_settings: ExLlamaV2Sampler.Settings 객체로 지정하는 샘플러 설정

다중 생성을 하려면 프롬프트에 문자열 리스트를 넘기면 돼요. 샘플 설정과 정지 조건도 함께 넣어볼게요.

from exllamav2.generator import ExLlamaV2Sampler

prompts = [
    "Five good reasons to adopt a cat:",
    "Here's why dogs are awful:",
    "Cats are better than dogs because"
]

gen_settings = ExLlamaV2Sampler.Settings(
    temperature = 0.9,
    top_p = 0.8,
    token_repetition_penalty = 1.025
)

outputs = generator.generate(
    prompt = prompts,
    max_new_tokens = 200,
    stop_conditions = [tokenizer.eos_token_id],
    gen_settings = gen_settings,
    add_bos = True
)

for o in outputs:
    print(o)

gen_settings는 모든 잡에 적용할 ExLlamaV2Sampler.Settings 객체 하나, 또는 잡마다 따로 주고 싶으면 리스트로 넘겨도 돼요.

스트리밍 모드

generate는 내부적으로 잡들을 만들고 스트리밍 루프를 돌려, 배치의 완성본을 전부 모아 한 번에 반환해요. 더 세밀히 제어하고 싶으면 잡을 직접 만들어 결과가 생길 때마다(토큰 단위로든 잡 끝에든) 받으면 돼요.

유연성을 위해 잡 생성자는 텍스트 프롬프트가 아니라 input IDs를 받아요. 그 외 인자는 generate와 거의 같아요.

from exllamav2.generator import ExLlamaV2DynamicJob

for idx, prompt in enumerate(prompts):
    job = ExLlamaV2DynamicJob(
        input_ids = tokenizer.encode(prompt, add_bos = True),
        max_new_tokens = 200,
        stop_conditions = [tokenizer.eos_token_id],
        gen_settings = gen_settings,
        identifier = idx
    )
    generator.enqueue(job)

identifier는 각 잡에 붙이는 선택적 사용자 정의 객체예요. 잡에 관한 모든 출력과 함께 돌아오는데, 제너레이터가 작업량을 스스로 스케줄하기 때문에 출력을 원래 프롬프트에 연결하기 쉽게 쓰라고 마련된 거예요.

잡이 큐에 들어갔으니 iterate를 잡이 남지 않을 때까지 루프로 돌려요.

# Somewhere to store the streaming results
collected_outputs = [""] * len(prompts)

while generator.num_remaining_jobs():
    results = generator.iterate()

    # iterate() always returns a list of zero or more result dicts
    for result in results:

        # Find out which job this result pertains to
        idx = result["identifier"]

        # The text key will only be present during the streaming stage and may be an empty string
        text_chunk = result.get("text", "")

        # Stream just the first sequence to the console (could get confusing otherwise)
        if idx == 0:
            print(text_chunk, end = "")

        # Collect all the outputs
        collected_outputs[idx] += text

print()
for idx, o in enumerate(collected_outputs):
    print(idx, o)

iterate는 메트릭과 여러 세부 정보도 반환하고, 잡 설정에 따라 logits·토큰 확률 등도 포함할 수 있어요. 자세한 건 문서스트링을 참고하세요.

더 알아보기

  • ExLlamaV2 개요 — 설치·EXL2·생태계 전체 그림
  • EXL2 양자화 변환convert.py로 모델을 EXL2로 양자화하는 법
  • 평가 스크립트 — 다이내믹 배칭으로 돌리는 HumanEval·MMLU 벤치마크
  • 원본: Dynamic generator 문서