공지TechKnowGenie 블로그를 오픈했습니다.
•읽는 데 약 28분

FlashDecoding++: 시퀀스 차원 KV 분할 병렬화와 GPU 언더유틸라이제이션 극복을 통한 LLM 서빙 극대화

싱글 토큰 생성 디코딩 단계에서 1% 미만으로 급락하는 GPU SM 점유율을 시퀀스 차원 KV-Split 병렬 처리와 비동기 Partial Softmax 축소로 해결하여 서빙 속도를 최대 8배 가속하는 FlashDecoding++ 아키텍처 심층 분석

관리자
목차 (6)
  1. 프리필과 디코딩의 연산 비대칭성과 SM 점유율 붕괴 원인
  2. FlashDecoding++ 핵심 아키텍처: 시퀀스 차원 KV 분할 병렬화
  3. 부분 소프트맥스와 2단계 비동기 전역 축소 파이프라인
  4. Flat GEMM 연산 최적화 및 양자화 친화적 커널 융합
  5. FlashDecoding 시퀀스 분할 에뮬레이터 구현 코드
  6. 실전 프로덕션 벤치마크 및 LLM 서빙 프레임워크 통합

대규모 언어 모델 서빙 시스템을 구축하고 운영하는 엔지니어들이 현업에서 마주하는 가장 뼈아픈 병목 구간은 입력 프롬프트를 한꺼번에 처리하는 프리필 단계가 아니라, 사용자의 화면에 답변 글자를 한 자씩 스트리밍으로 출력하는 자기회귀 디코딩 단계에서 발생합니다. 최신 AI 하드웨어 가속기인 NVIDIA H100 Tensor Core GPU나 A100 GPU는 수백 개에 달하는 스트리밍 멀티프로세서(SM)와 페타플롭스급 연산 대역폭을 보유하고 있습니다. 그러나 단일 쿼리 토큰을 생성하는 디코딩 환경에서 기존 커널을 구동하면, 하드웨어 연산 자원의 1퍼센트에서 3퍼센트 남짓만을 활용한 채 대부분의 연산 코어가 유휴 상태로 잠드는 극심한 언더유틸라이제이션 현상이 발생합니다.

프리필 단계에서는 방대한 길이의 프롬프트 토큰들이 서로 간의 행렬 곱셈을 형성하므로 전형적인 연산 집약적 GEMM(일반 행렬 곱셈) 특성을 띱니다. 따라서 온칩 고속 메모리인 SRAM 타일링을 활용하는 플래시어텐션 커널이 텐서 코어를 최대 한계까지 몰아붙여 경이로운 처리량을 보여줍니다.

하지만 디코딩 단계에 진입하는 순간 수만 토큰에 달하는 이전 컨텍스트에 대해 새롭게 입력되는 쿼리는 오직 단 1개의 토큰뿐입니다. 이로 인해 연산의 본질은 행렬 곱셈에서 단일 벡터와 거대 행렬 간의 곱셈인 GEMV 형태로 전락하며, 연산 집약도가 수백 배 이하로 급락하는 극단적인 메모리 대역폭 제한 연산으로 성격이 바뀝니다. 플래시어텐션-2조차 단일 쿼리 디코딩 환경에서는 GPU 멀티프로세서 전반에 충분한 병렬 스레드 블록을 스케줄링하지 못하여 하드웨어 자원을 방치하게 됩니다.

FlashDecoding 계열 기술과 한 단계 더 나아간 FlashDecoding++는 이러한 하드웨어 불균형을 해결하기 위해 연산 병렬화의 기본 축 자체를 재정의합니다. 전통적으로 배치 차원과 어텐션 헤드 차원에 국한되어 있던 GPU 스레드 블록 스케줄링 패러다임을 혁신하여, 거대한 시퀀스 차원의 키-값 캐시를 독립적인 청크 단위로 세분화하고 이를 수백 개의 멀티프로세서에 고르게 분산시키는 시퀀스 스플릿 병렬화를 완성했습니다.

유휴 상태로 방치되던 GPU 하드웨어 점유율을 100퍼센트 수준으로 끌어올리고, 64k나 128k 이상의 초장문맥 환경에서 단일 토큰 생성 지연 시간을 최대 8배 이상 단축시키는 FlashDecoding++의 설계 사상과 핵심 커널 아키텍처를 상세히 파헤쳐 보겠습니다.

FlashDecoding++ Sequence Split Architecture

프리필과 디코딩의 연산 비대칭성과 SM 점유율 붕괴 원인

거대 언어 모델 서빙 시스템의 라이프사이클은 하드웨어 연산 관점에서 완벽히 상반되는 두 개의 국면으로 쪼개집니다.

첫 번째 국면인 프리필 단계는 모델이 질문 프롬프트를 전달받아 문맥 전체를 한 번에 이해하는 과정입니다. 시퀀스 길이가 N일 때, 쿼리와 키의 내적 연산량은 N의 제곱에 비례하여 증가합니다. 이 구간에서는 고밀도 행렬 간의 곱셈이 텐서 코어 내부에서 끊임없이 순환하므로, GPU의 연산 유닛이 최대 전력으로 구동되며 메모리 전송 시간보다 연산 수행 시간이 지배적인 연산 집약적 특성을 강하게 나타냅니다.

반면 두 번째 국면인 자기회귀 디코딩 단계에서는 매 생성 스텝마다 오직 한 개의 새로운 토큰만이 추가됩니다. 이 새로운 단일 토큰은 이전에 생성되어 고대역폭 메모리에 적재된 N개의 키-값 캐시 텐서 전체를 순회하며 어텐션 점수를 계산해야 합니다.

수학적으로 쿼리 텐서의 형태는 배치 크기와 헤드 수, 그리고 시퀀스 길이 1로 축소됩니다. 키와 값 텐서의 형태는 배치 크기와 헤드 수, 그리고 과거의 전체 시퀀스 길이 N으로 유지됩니다. 즉, 연산은 1차원 벡터와 거대 2차원 행렬 간의 곱셈입니다.

산술 연산 강도는 메모리에서 읽어오는 데이터 1바이트당 수행하는 부동소수점 연산의 횟수로 정의됩니다. 프리필 단계의 산술 연산 강도가 수십에서 수백 FLOPs/Byte에 달하는 반면, 디코딩 단계의 산술 연산 강도는 1 FLOPs/Byte 미만으로 곤두박질칩니다. 텐서 코어가 단 몇 클록 사이클 만에 쿼리와 키 벡터의 내적을 끝내더라도, 다음 키 캐시 블록이 고대역폭 메모리에서 버스를 타고 온칩 메모리로 도착할 때까지 수백 클록 동안 아무 일도 하지 못하고 대기해야 합니다.

더욱 치명적인 문제는 GPU의 물리적 하드웨어 구조와 스케줄러 디스패치 방식 간의 구조적 충돌에서 비롯됩니다. 최신 엔터프라이즈 GPU인 NVIDIA H100 SXM은 132개의 스트리밍 멀티프로세서를 내장하고 있으며, A100은 108개의 멀티프로세서를 가지고 있습니다. 각 멀티프로세서는 독립적인 워프 스케줄러와 공유 메모리, 레지스터 파일, 텐서 코어를 독립적으로 운용합니다.

기존의 플래시어텐션-2 및 일반적인 어텐션 구현체들은 CUDA 스레드 블록 그리드를 구성할 때 오직 두 가지 차원, 즉 배치 크기와 어텐션 헤드 수만을 기준으로 그리드 크기를 설정했습니다. 만약 실시간 인터랙티브 서비스에서 응답 지연 시간을 극도로 줄이기 위해 배치 크기를 1로 두고 모델을 구동한다고 가정해 보겠습니다.

최신 오픈소스 모델인 LLaMA-3나 미스트랄 계열이 멀티 헤드 어텐션에서 32개의 헤드를 사용하거나, 연산 효율을 높이기 위해 그룹 쿼리 어텐션을 적용하여 키-값 헤드 수를 8개로 줄인 경우 상황은 재앙으로 변합니다.

헤드 수가 32개이고 배치 크기가 1이라면, GPU 하드웨어에 디스패치되는 전체 스레드 블록의 개수는 오직 32개에 불과합니다. 132개의 멀티프로세서를 가진 H100 GPU에서 고작 32개의 멀티프로세서만이 1개의 스레드 블록을 배정받아 실행되고, 나머지 100개의 멀티프로세서는 전력이 공급된 채 아무런 작업도 수행하지 못하는 완전한 유휴 상태에 빠집니다. 그룹 쿼리 어텐션 환경에서 키-값 헤드가 8개라면 124개의 멀티프로세서가 놀게 됩니다.

설상가상으로, 단 1개의 멀티프로세서에 할당된 스레드 블록 하나가 16k, 64k, 128k에 달하는 방대한 과거 캐시 벡터 전체를 순차적으로 루프를 돌며 처리해야 합니다. 다른 100개의 멀티프로세서가 손을 놓고 구경하는 동안, 선택받은 소수의 멀티프로세서만이 과도한 작업량에 짓눌려 긴 시간 동안 직렬 연산을 수행하게 되는 것입니다. 이것이 바로 긴 문맥 서빙 환경에서 기존 커널의 지연 시간이 시퀀스 길이에 정비례하여 기하급수적으로 폭증하던 근본 원인입니다.

FlashDecoding++ 핵심 아키텍처: 시퀀스 차원 KV 분할 병렬화

FlashDecoding과 FlashDecoding++는 이러한 GPU 하드웨어 불균형을 타파하기 위해 병렬화의 차원을 완전히 새롭게 정의합니다. 스레드 블록을 생성하는 그리드 차원에 시퀀스 길이 축을 세 번째 독립 병렬 축으로 직접 편입시키는 것입니다.

시퀀스 차원 키-값 분할 병렬화의 설계 원리는 명쾌합니다. 수만 토큰에 이르는 전체 시퀀스 길이를 일정한 고정 청크 단위로 세분화합니다. 하드웨어 아키텍처와 헤드 수에 따라 청크 크기는 통상 128, 256, 또는 512 토큰 단위로 설정됩니다.

이제 CUDA 그리드 스케줄러는 배치 크기와 헤드 수뿐만 아니라, 전체 시퀀스를 청크 크기로 나눈 청크의 개수를 곱하여 총 스레드 블록의 수를 결정합니다.

예를 들어 배치 크기가 1이고 어텐션 헤드가 32개이며, 문맥 길이가 65536 토큰(64k)인 환경을 고려해 보겠습니다. 청크 크기를 256으로 설정하면, 시퀀스 차원에서 정확히 256개의 독립된 청크가 생성됩니다. 따라서 전체 스케줄링되는 스레드 블록의 수는 1에 32를 곱하고 여기에 256을 곱하여 무려 8192개에 달하게 됩니다.

8192개의 스레드 블록은 132개의 H100 멀티프로세서 전반에 빈틈없이 균등하게 디스패치됩니다. 각 멀티프로세서는 약 62개의 스레드 블록을 작업 대기열에 적재하게 되며, 워프 스케줄러는 하나의 블록이 고대역폭 메모리에서 데이터를 가져오는 동안 다른 블록의 연산을 즉각 교차 실행하여 메모리 지연 시간을 완벽하게 은닉합니다.

단일 쿼리 토큰은 모든 스레드 블록에 공유 메모리 브로드캐스트 방식으로 복제되어 온칩 고속 메모리에 상주합니다. 그리고 각각의 스레드 블록은 오직 자신에게 배정된 고유한 256 토큰 크기의 키-값 캐시 조각만을 고대역폭 메모리에서 로컬 공유 메모리로 비동기 복사하여 로컬 어텐션 연산을 즉각 완료합니다. 모든 멀티프로세서가 동시에 텐서 코어를 회전시키므로, 하드웨어 점유율은 단숨에 이론적 한계치인 100퍼센트에 도달합니다.

부분 소프트맥스와 2단계 비동기 전역 축소 파이프라인

시퀀스 차원을 잘게 쪼개어 수천 개의 스레드 블록으로 분산 실행할 때 마주치는 수학적 난제는 바로 소프트맥스 함수의 전역 정규화 성질입니다.

소프트맥스 함수는 전체 시퀀스 내의 모든 로짓 값들 중 글로벌 최대값을 찾아 지수 함수의 수치적 오버플로우를 방지하고, 모든 지수 값들의 총합인 글로벌 분모로 각 원소를 나누어 주는 정규화 단계를 필연적으로 요구합니다.

그러나 시퀀스가 256개의 독립된 청크로 분할되어 서로 다른 멀티프로세서에서 병렬로 연산 중이라면, 개별 스레드 블록은 자신이 담당하지 않은 다른 청크들의 로짓 값이나 전체 시퀀스의 글로벌 최대값을 사전에 알 수 있는 방법이 없습니다.

FlashDecoding++는 이 문제를 해결하기 위해 오차 없는 수학적 재정규화 공식을 기반으로 한 2단계 분할 감축 파이프라인을 구축합니다.

1단계: 로컬 청크 병렬 연산 및 중간 통계량 기록

첫 번째 단계에서 각 멀티프로세서의 스레드 블록은 자신에게 할당된 로컬 청크 범위 내에서만 부분 어텐션 연산을 수행합니다. 온칩 공유 메모리 내부에서 단일 쿼리 벡터와 256개 로컬 키 벡터들의 내적을 계산하여 로컬 로짓을 산출합니다.

그 후 해당 로컬 로짓 벡터 내에서의 로컬 최대값을 추출합니다. 이 로컬 최대값을 각 로짓에서 감산하여 지수 함수를 안전하게 계산하고, 로컬 지수 값들의 합인 로컬 합을 계산합니다. 마지막으로 이 로컬 확률 가중치를 로컬 값 벡터와 곱하여 중간 부분 결과 텐서를 완성합니다.

연산이 끝난 각 스레드 블록은 최종 결과를 직접 완성하려 시도하지 않습니다. 대신 오직 세 가지 핵심 데이터만을 글로벌 고대역폭 메모리의 전용 임시 버퍼 공간에 기록하고 즉시 종료됩니다.

그 세 가지 데이터는 로컬 청크의 로컬 부분 출력 벡터, 로컬 최대값 스칼라, 그리고 로컬 지수 합 스칼라입니다. 각 청크당 기록해야 하는 통계량 메타데이터는 단 몇 바이트에 불과하므로 메모리 쓰기 대역폭 낭비가 사실상 발생하지 않습니다.

2단계: 글로벌 리덕션 커널을 통한 수학적 재정규화

모든 청크 블록의 1단계 연산이 완료되면, 가벼운 글로벌 리덕션 커널이 호출됩니다. 이 커널은 각 어텐션 헤드별로 단 하나의 스레드 블록만을 띄워 처리할 수 있을 만큼 극도로 가볍고 빠릅니다.

리덕션 커널은 해당 헤드에 속한 256개의 로컬 통계량들을 온칩 공유 메모리로 단번에 읽어 들입니다. 먼저 256개의 로컬 최대값들 중 가장 큰 값을 찾아내어 전체 시퀀스의 진정한 글로벌 최대값을 확정합니다.

그 후 정교한 수학적 보정 계수를 산출합니다. 각 청크의 로컬 최대값과 확정된 글로벌 최대값의 차이에 대해 지수 함수를 취한 뒤, 이를 기존 로컬 합 및 로컬 부분 출력 벡터에 곱해 줌으로써 글로벌 기준에 맞게 스케일을 보정합니다.

보정된 로컬 합들을 모두 더하여 진정한 글로벌 분모 합을 완성하고, 보정된 부분 출력 벡터들을 누적 합산한 뒤 최종 글로벌 분모 합으로 나누어 줌으로써 단일 통합 어텐션 출력 벡터를 완벽하게 복원합니다.

이러한 2단계 축소 기법은 수학적으로 정확히 단일 플래시어텐션을 수행한 결과와 소수점 오차 한계 내에서 완벽하게 일치하며, 수만 토큰에 달하는 직렬 루프를 단일 축소 트리 연산으로 대체하여 레이턴시를 획기적으로 삭감합니다.

Flat GEMM 연산 최적화 및 양자화 친화적 커널 융합

FlashDecoding++의 혁신은 시퀀스 분할에만 국한되지 않고, 디코딩 단계 전반에 존재하는 고유한 연산 패턴을 극대화하는 커널 융합 기술로 확장됩니다. 대표적인 최적화 영역이 바로 Flat GEMM 가속과 저정밀도 양자화 데이터 타입의 네이티브 통합입니다.

디코딩 단계에서 자기회귀 모델의 피드포워드 네트워크나 투영 레이어는 거대한 가중치 행렬과 단일 토큰 1차원 벡터 간의 곱셈을 빈번하게 수행합니다. 이를 연산 관점에서 Flat GEMM이라 부릅니다.

전통적인 행렬 곱셈 라이브러리인 cuBLAS는 정방형에 가까운 대규모 행렬 곱셈에는 최적화되어 있으나, 한쪽 차원이 1인 극단적인 직사각형 형태의 연산에서는 심각한 스케줄링 오버헤드를 발생시킵니다. FlashDecoding++는 디코딩 형상에 특화된 경량 매크로 커널을 제공하여 커널 런치 오버헤드를 제거하고 공유 메모리 뱅크 충돌을 원천 차단합니다.

또한 초장문맥 서빙의 최대 걸림돌인 키-값 캐시 메모리 풋프린트를 극복하기 위해, FP8 및 INT4 정밀도에 대한 온더플라이 디퀀타이제이션 커널 융합을 기본 내장합니다. 고대역폭 메모리에 압축되어 있는 양자화된 키와 값 텐서를 로드하는 즉시, 글로벌 메모리 단계가 아닌 온칩 레지스터 파일 내부에서 곧바로 FP16 또는 BF16 형식으로 역양자화하여 텐서 코어 레지스터로 직결합니다.

이를 통해 고대역폭 메모리에서 끌어오는 전송 데이터 바이트 수를 정밀도에 따라 절반에서 4분의 1 수준으로 줄이면서도, 레지스터 내부에서의 연산 정밀도는 온전히 유지하여 양자화로 인한 품질 손실 없이 극대화된 서빙 처리량을 구현합니다.

FlashDecoding 시퀀스 분할 에뮬레이터 구현 코드

FlashDecoding++의 핵심 수학적 구조와 시퀀스 분할 리덕션 파이프라인을 명확히 이해하기 위해, 순수 파이썬 및 파이토치 텐서 연산으로 구축한 아키텍처 구현체입니다.

곱셈 기호나 인라인 번호 매김 없이, 안전한 텐서 메서드와 수학 함수만을 활용하여 분할 병렬화와 2단계 리덕션 수치 보정 과정을 재현합니다.

# FlashDecoding Sequence-Split Parallel Attention Emulator
# Hardware-aware KV chunking and two-phase global reduction logic

import torch
import math

class SplitKVAttentionEmulator(torch.nn.Module):
    def __init__(self, hidden_dim, num_heads, chunk_size=256):
        super().__init__()
        self.hidden_dim = hidden_dim
        self.num_heads = num_heads
        self.head_dim = hidden_dim // num_heads
        self.chunk_size = chunk_size
        self.scale = 1.0 / math.sqrt(float(self.head_dim))

    def forward(self, query, key_cache, value_cache):
        # query shape: [batch_size, num_heads, 1, head_dim]
        # key_cache shape: [batch_size, num_heads, seq_len, head_dim]
        # value_cache shape: [batch_size, num_heads, seq_len, head_dim]
        
        batch_size, num_heads, query_len, head_dim = query.shape
        seq_len = key_cache.shape[2]
        
        # Calculate number of sequence chunks
        num_chunks = int(math.ceil(float(seq_len) / float(self.chunk_size)))
        
        # Phase 1: Local Chunk Computation (Simulating parallel SM thread blocks)
        local_outputs = []
        local_maxes = []
        local_sums = []
        
        for chunk_idx in range(num_chunks):
            start_pos = math.prod([chunk_idx, self.chunk_size])
            end_pos = min(start_pos + self.chunk_size, seq_len)
            
            # Slice KV chunk from global memory to simulated on-chip SRAM
            k_chunk = key_cache[:, :, start_pos:end_pos, :]
            v_chunk = value_cache[:, :, start_pos:end_pos, :]
            
            # Scaled Dot-Product on local chunk: [B, H, 1, D] @ [B, H, D, Chunk_Len]
            scores = torch.matmul(query, k_chunk.transpose(-2, -1)).mul(self.scale)
            
            # Local Max per head
            chunk_max, _ = torch.max(scores, dim=-1, keepdim=True)
            
            # Numerically stable local exp
            exp_scores = torch.exp(scores - chunk_max)
            
            # Local sum
            chunk_sum = torch.sum(exp_scores, dim=-1, keepdim=True)
            
            # Local partial output: [B, H, 1, Chunk_Len] @ [B, H, Chunk_Len, D]
            chunk_out = torch.matmul(exp_scores, v_chunk)
            
            local_outputs.append(chunk_out)
            local_maxes.append(chunk_max)
            local_sums.append(chunk_sum)
            
        # Stack chunks along a new dimension for reduction
        # Shape: [B, H, Num_Chunks, 1, D] and [B, H, Num_Chunks, 1, 1]
        stacked_outputs = torch.stack(local_outputs, dim=2)
        stacked_maxes = torch.stack(local_maxes, dim=2)
        stacked_sums = torch.stack(local_sums, dim=2)
        
        # Phase 2: Global Reduction Kernel
        # Find global maximum across all sequence chunks
        global_max, _ = torch.max(stacked_maxes, dim=2, keepdim=True)
        
        # Compute rescaling factors for each chunk: exp(local_max - global_max)
        rescaling_factors = torch.exp(stacked_maxes - global_max)
        
        # Rescale local sums and sum them up to get global denominator
        rescaled_sums = stacked_sums.mul(rescaling_factors)
        global_sum = torch.sum(rescaled_sums, dim=2, keepdim=True)
        
        # Rescale local partial outputs and sum them up
        rescaled_outputs = stacked_outputs.mul(rescaling_factors)
        summed_output = torch.sum(rescaled_outputs, dim=2, keepdim=True)
        
        # Final normalized attention output
        final_output = summed_output / (global_sum + 1e-8)
        
        # Squeeze out reduction dimension: [B, H, 1, D]
        return final_output.squeeze(2)

def benchmark_flashdecoding_equivalence():
    torch.manual_seed(42)
    device = "cpu"
    
    batch_size = 1
    num_heads = 32
    head_dim = 128
    seq_len = 8192
    hidden_dim = math.prod([num_heads, head_dim])
    
    query = torch.randn(batch_size, num_heads, 1, head_dim, device=device)
    key_cache = torch.randn(batch_size, num_heads, seq_len, head_dim, device=device)
    value_cache = torch.randn(batch_size, num_heads, seq_len, head_dim, device=device)
    
    # Ground Truth: Standard Global Attention
    scale = 1.0 / math.sqrt(float(head_dim))
    full_scores = torch.matmul(query, key_cache.transpose(-2, -1)).mul(scale)
    full_probs = torch.softmax(full_scores, dim=-1)
    ref_output = torch.matmul(full_probs, value_cache)
    
    # FlashDecoding Split Emulator
    emulator = SplitKVAttentionEmulator(hidden_dim, num_heads, chunk_size=512)
    split_output = emulator(query, key_cache, value_cache)
    
    # Verification
    diff = torch.max(torch.abs(ref_output - split_output)).item()
    print(f"Equivalence Check: Maximum Absolute Deviation = {diff:.2e}")
    assert diff < 1e-5, "Mathematical deviation exceeded tolerance threshold!"

if __name__ == "__main__":
    benchmark_flashdecoding_equivalence()

위 구현체는 실제 GPU 하드웨어에서 수백 개의 멀티프로세서가 수행하는 작업을 단일 프로세스 관점에서 추적할 수 있도록 설계되었습니다.

코드에서 주목할 핵심 지점은 각 청크가 계산한 로컬 가중치에 리스케일링 팩터를 곱하여 전역 기준 최대값으로 일치시키는 대목입니다. 이를 통해 분할 연산 시 발생하는 수학적 비정합성을 완벽하게 해소하며, 오차 한계 1e-5 미만의 엄밀한 수학적 동일성을 보장합니다.

실전 프로덕션 벤치마크 및 LLM 서빙 프레임워크 통합

FlashDecoding 계열 최적화는 실제 프로덕션 서빙 시스템에 도입되었을 때 드라마틱한 성능 지표 개선을 입증합니다. 특히 문맥 길이가 길어질수록 기존 플래시어텐션-2 대비 지연 시간 격차가 가파르게 벌어집니다.

배치 크기 1의 LLaMA-70B 모델을 H100 GPU 환경에서 서빙할 때의 문맥 길이별 토큰 생성 지연 시간(Inter-Token Latency) 측정 결과는 다음과 같은 양상을 보입니다.

시퀀스 길이가 4096 토큰인 짧은 문맥에서는 플래시어텐션-2 대비 약 1.8배에서 2.2배의 가속을 기록합니다. 이 구간에서는 기존 방식에서도 어느 정도의 청크 작업이 이루어지므로 상대적 개선 폭이 완만합니다.

그러나 시퀀스 길이가 16k를 넘어서 64k, 128k로 진입하는 초장문맥 환경에서는 그 차이가 폭발적으로 확대됩니다. 64k 문맥에서 플래시어텐션-2는 단일 스레드 블록이 수만 개의 키-값 벡터를 순차 순회하느라 토큰당 생성 지연 시간이 45밀리초 수준까지 치솟는 반면, FlashDecoding++는 256개의 청크가 132개의 멀티프로세서 전체를 가득 채워 동시 병렬 처리함으로써 토큰당 생성 시간을 6밀리초대로 압축하여 7배 이상의 속도 향상을 달성합니다.

최적 청크 크기 튜닝 가이드

서빙 인프라 엔지니어가 FlashDecoding++ 커널을 시스템에 통합할 때 가장 주의 깊게 튜닝해야 하는 파라미터는 바로 시퀀스 분할 청크 크기입니다.

청크 크기를 지나치게 작게 설정하면 생성되는 스레드 블록의 수가 급증하여 2단계 글로벌 리덕션 커널이 합산해야 하는 중간 통계량의 양이 늘어납니다. 이는 리덕션 단계의 메모리 대역폭 부하를 가중시키고 전역 메모리 동기화 지연을 초래합니다.

반대로 청크 크기를 지나치게 크게 설정하면 스케줄링되는 스레드 블록의 총개수가 GPU 멀티프로세서의 정수 배수에 도달하지 못해, 일부 멀티프로세서가 일찍 연산을 마치고 노는 테일 레이턴시 문제가 다시 재발합니다.

가장 이상적인 청크 크기는 서빙 중인 어텐션 헤드 수와 대상 GPU의 물리적 멀티프로세서 개수에 의해 결정됩니다. 전체 스레드 블록의 개수가 물리적 멀티프로세서 수의 최소 4배에서 8배 이상이 되도록 청크 크기를 유동적으로 적응 분할하는 동적 스플릿 전략이 프로덕션 엔진에서 권장됩니다.

vLLM 및 TensorRT-LLM 엔진 연동 아키텍처

최신 오픈소스 추론 엔진들은 FlashDecoding 메커니즘을 페이지드 어텐션 구조와 결합하여 완성형 솔루션으로 통합하고 있습니다.

vLLM 프레임워크에서는 블록 단위로 불연속하게 할당된 가상 메모리 페이징 테이블 위에서 각 물리 블록 인덱스를 FlashDecoding의 청크 슬라이스와 1대1 매핑하는 방식으로 커널을 호출합니다. 이를 통해 물리 메모리 파편화를 완전히 방지하면서도 시퀀스 차원 병렬성을 그대로 유지합니다.

TensorRT-LLM 역시 XQA 커널 패밀리 내부에 FlashDecoding 알고리즘을 코어로 탑재하여, FP8 양자화 가중치 및 KV 캐시와 결합된 하이브리드 파이프라인을 기본 제공합니다. 고성능 에이전트 서비스나 대규모 문서 분석 파이프라인과 같이 수만 토큰 이상의 긴 프롬프트를 유지하면서 실시간 스트리밍 답변을 제공해야 하는 엔터프라이즈 환경에서, FlashDecoding++ 아키텍처는 GPU 인프라 비용을 수분의 일로 절감하는 필수적인 핵심 가속 기술로 자리 잡고 있습니다.

← 목록으로