●○○개념 확인계산
프롬프트 180토큰짜리 요청이 들어와 41토큰을 생성하고 <EOS>로 끝났습니다. 블록 크기는 기본값 16토큰입니다.
이 요청이 끝나는 시점에 점유하고 있는 물리 블록 수와, 마지막 블록에 남은 빈칸 수는 각각 몇 개입니까?
블록 14개를 쓰고 마지막 블록에 13칸이 빕니다.
221을 16으로 나눈 나머지 13은 마지막 블록에 들어찬 칸 수이지 빈칸 수가 아닙니다. 빈칸은 16에서 그 값을 뺀 3칸입니다. 채워진 칸과 빈칸을 뒤집어 세는 실수가 자주 나옵니다.
정답블록 14개를 쓰고 마지막 블록에 3칸이 빕니다.
전체 토큰은 180 더하기 41로 221개이고, 16으로 나누면 13.8이므로 블록 14개가 필요합니다. 14개는 224칸이니 224에서 221을 빼면 빈칸은 3칸입니다. 앞의 13개 블록은 모두 꽉 차 있습니다.
블록 15개를 쓰고 마지막 블록에 7칸이 빕니다.
프롬프트와 출력이 각각 자기 블록에서 시작한다고 보면 12개와 3개로 나뉘어 15개가 되고, 출력 쪽 마지막 블록에 7칸이 빕니다. 실제로는 논리 블록이 시퀀스 전체에 걸쳐 왼쪽부터 순서대로 채워지므로, 프롬프트의 마지막 토큰과 첫 출력 토큰이 같은 블록 안에 나란히 들어갑니다.
요청의 max_tokens 값에 따라 정해져 221토큰만으로는 알 수 없습니다.
max_tokens만큼 미리 잡아두는 것은 연속 할당 방식의 동작입니다. 블록은 앞 블록이 다 찬 뒤에야 하나씩 새로 붙으므로, 점유량은 실제로 만들어진 토큰 수만으로 정해집니다.
해설
블록 수는 전체 토큰 수를 블록 크기로 나눠 올림한 값이고, 빈칸은 그 결과에 블록 크기를 곱한 값에서 토큰 수를 뺀 값입니다. 나머지 연산으로 나오는 값은 마지막 블록에 채워진 칸 수라서, 그대로 빈칸이라고 읽으면 답이 뒤집힙니다. 논리 블록은 프롬프트 구간과 출력 구간을 구분하지 않고 시퀀스 처음부터 왼쪽에서 오른쪽으로 이어서 채워집니다. 그래서 프롬프트의 끝과 출력의 시작이 한 블록을 나눠 쓰고, 경계에서 블록이 하나 더 생기는 일이 없습니다. 빈칸이 생길 수 있는 자리도 항상 마지막 블록 하나뿐입니다. 시퀀스가 아무리 길어져도 이 낭비는 블록 크기 미만으로 제한되고, 블록 크기 16에서는 시퀀스당 최대 15칸입니다. 시퀀스가 길수록 전체에서 이 15칸이 차지하는 비중은 작아집니다.
이 내용 다시 읽기 →●●○상황 적용함정면접
팀 위키에 이렇게 적혀 있습니다. "vLLM은 PagedAttention으로 요청당 KV Cache 크기를 줄이기 때문에, 같은 GPU에 더 많은 요청을 담을 수 있다."
이 문장의 결론은 맞습니다. 그렇게 되는 실제 이유는 무엇입니까?
어텐션 커널이 블록 단위로 KV를 읽어 계산이 빨라진 만큼 요청을 더 받게 된 것입니다.
PagedAttention 커널은 블록 테이블을 따라가며 흩어진 블록을 모아 읽으므로, 연속 배열을 그대로 훑는 커널보다 어텐션 자체는 오히려 조금 느립니다. 동시에 올라가는 요청 수를 늘린 것은 커널 속도가 아니라 점유 방식의 변화입니다.
KV를 16토큰 단위로 묶어 저장하면서 형식이 조밀해져 실제 바이트 수가 줄어듭니다.
블록으로 나누는 것은 저장 위치를 쪼개는 일이지 값을 압축하는 일이 아닙니다. 같은 토큰의 K와 V는 블록에 담기든 연속 배열에 담기든 바이트 수가 같습니다. 저장 바이트를 실제로 줄이는 것은 KV 양자화 같은 별개의 기법입니다.
정답토큰당 KV 크기는 그대로이고, 아직 쓰지 않는 자리를 붙잡지 않게 된 것입니다.
토큰 하나가 차지하는 K와 V의 크기는 레이어 수, 헤드 수, 헤드 차원, 자료형으로 정해지고 PagedAttention은 그중 어느 것도 건드리지 않습니다. 달라진 것은 아직 생성하지 않은 토큰의 자리를 미리 잡아두지 않는다는 점입니다. 100토큰을 만든 요청은 100토큰치만 점유하고 나머지는 다른 요청이 씁니다.
요청이 끝나기 전에도 앞쪽 블록을 반납해 KV를 돌려 쓰게 된 것입니다.
앞쪽 블록은 뒤에 생성되는 토큰의 어텐션이 계속 읽어야 하므로, 윈도우 밖을 보지 않는 sliding window 레이어가 아니라면 생성 도중에 내놓을 수 없습니다. 생성 중에 KV를 내려놓는 것은 선점될 때 재계산을 택하는 별개의 동작이고, 낭비가 사라진 이유가 아닙니다.
해설
PagedAttention은 KV Cache를 작게 만드는 기술이 아니라 한정된 KV 공간을 남김없이 쓰게 하는 기술입니다. 토큰 하나의 KV 크기는 모델 구조와 자료형이 정하고, 블록으로 나눈다고 그 값이 달라지지 않습니다. 달라지는 것은 점유 방식입니다. 연속 할당은 요청이 앞으로 쓸지도 모르는 최대 길이만큼을 처음부터 붙잡아, 실제로 쓰는 양과 점유하는 양이 크게 벌어집니다. 블록 단위로 필요할 때만 붙이면 그 둘이 거의 같아지고, 남는 공간은 곧바로 다른 요청 몫이 됩니다. 그래서 요청당 KV는 그대로인데도 같은 GPU에 동시에 올라가는 요청 수가 늘어납니다. 연속 할당 시스템에서 KV 공간 중 실제 토큰 상태를 담는 데 쓰인 비율은 논문 프로파일링에서 20.4%에서 38.2%에 그쳤고, 나머지는 예약과 단편화로 비어 있었습니다.
이 내용 다시 읽기 →●●○상황 적용진단구조
[kv-alloc] total 8192 slots | in use 7292 | free 900
[kv-alloc] free runs: 384, 256, 160, 100
[sched] req-2041 needs 512 contiguous slots
[sched] req-2041 REJECTED: no contiguous run large enough
빈 자리가 900칸 남았는데 512칸짜리 요청이 거절됐습니다. 같은 상태에서 KV를 16토큰 블록으로 관리하도록 바꾸면 이 요청은 어떻게 되고, 그렇게 되는 이유는 무엇입니까?
정답수용됩니다. 필요한 블록 32개를 흩어진 빈 블록에서 모아 주면 됩니다.
모든 블록의 크기가 같으므로 빈 블록 하나는 어떤 요청의 어떤 논리 블록에도 들어갑니다. 블록 풀로 보면 남은 900칸은 빈 블록 56개이고, 512토큰은 블록 32개이니 거기서 32개를 꺼내 블록 테이블에 적어두면 그만입니다. 물리적으로 이어져 있을 필요가 전혀 없습니다.
여전히 거절됩니다. 어텐션 계산에 연속된 KV가 필요하다는 조건은 그대로입니다.
그 조건을 깨는 것이 PagedAttention 커널입니다. 블록 테이블을 따라가며 흩어진 블록을 읽어 어텐션을 계산하므로 KV가 물리적으로 이어져 있지 않아도 됩니다. 대신 블록 테이블을 참조하 고 블록마다 접근을 나누는 만큼 어텐션 커널 자체는 조금 느려집니다.
빈 조각을 앞으로 밀어 모으는 압축이 먼저 돌고 나서야 수용됩니다.
압축은 KV Cache에 현실적인 선택이 아닙니다. 옮겨야 할 데이터가 기가바이트 단위인데 그 이동이 매 스텝 지연에 그대로 얹히기 때문입니다. 블록 단위 관리는 조각을 모으는 대신 조각난 채로 쓸 수 있게 만들어 압축이 필요한 상황 자체를 없앱니다.
수용됩니다. 흩어진 900칸을 이어 붙여 512칸짜리 연속 구간으로 되돌릴 수 있습니다.
블록 단위 관리는 조각을 이어 붙이지 않습니다. 애초에 연속 구간을 만들 필요를 없애는 것이 요점이고, 흩어진 블록은 흩어진 채로 블록 테이블에 등록됩니다. 이어 붙여야 한다는 발상은 여전히 연속 할당의 사고에 머물러 있습니다.
해설
외부 단편화는 요청마다 요구하는 덩어리 크기가 다를 때만 생깁니다. 크기가 제각각이면 어느 요청에도 맞지 않는 어중간한 빈틈이 쌓이고, 총량은 충분한데 연속된 자리가 없어 거절하는 상황이 벌어집니다. 블록 단위 관리는 이 전제 자체를 없앱니다. 모든 블록이 같은 크기라 빈 블록 하나는 언제나 어떤 요청에든 들어가고, 못 쓰는 조각이라는 개념이 성립하지 않습니다. 이 성질은 블록 크기를 얼마로 잡든 유지되므로, 외부 단편화는 줄어드는 것이 아니라 사라집니다. 남는 낭비는 시퀀스의 마지막 블록에 생기는 빈칸뿐이고, 이것은 블록 안쪽에 생기는 내부 단편화입 니다. 조각을 한데 모으는 압축도 이론적으로는 답이지만, 옮겨야 할 KV가 너무 커서 서빙에서는 선택지가 되지 못합니다.
이 내용 다시 읽기 →●●●분석과 판단확장운영예측
vllm serve $MODEL --block-size 32
블록 크기를 기본값 16에서 32로 올렸습니다. 이 워크로드에서 무엇이 나빠집니까?
블록마다 크기가 달라져 어중간한 빈틈이 쌓이는 외부 단편화가 되살아납니다.
블록 크기를 32로 올려도 풀 안의 모든 블록은 여전히 서로 같은 크기입니다. 크기가 제각각일 때만 어느 요청에도 맞지 않는 빈틈이 생기므로, 큰 블록만 있는 풀에서도 빈 블록은 어떤 요청에든 들어갑니다. 커진 블록이 만드는 낭비는 블록 안쪽에 갇히는 내부 단편화입니다.
공통 시스템 프롬프트의 KV가 요청마다 따로 저장되어 접두어 공유 자체가 동작하지 않습니다.
블록 크기를 키워도 여러 요청의 블록 테이블이 같은 물리 블록을 가리키는 구조는 그대로라 공유는 계속 동작합니다. 달라지는 것은 공유가 성립하는 입도이고, 32의 배수까지만 맞아떨어져 재사용 구간이 짧아질 뿐입니다.
물리 블록 풀 전체 크기가 절반으로 줄어 담을 수 있는 총 토큰 수가 줄어듭니다.
풀 전체 크기는 GPU 메모리에서 모델 가중치와 활성화가 쓸 몫을 뺀 나머지로 정해지고, 블록 크기는 그 공간을 몇 조각으로 자를지만 정합니다. 블록 개수가 절반이 되는 대신 블록 하나가 두 배 커지므로 담을 수 있는 총 토큰 수는 거의 그대로입니다.
정답마지막 블록의 빈칸이 커지고 접두어 공유도 32토큰 단위로만 맞습니다.
평균 200토큰짜리 시퀀스는 32토큰 블록 7개, 즉 224칸을 잡고 24칸을 남깁니다. 블록 크기가 16이었을 때는 13개, 즉 208칸을 잡고 8칸만 남겼습니다. 게다가 블록 공유는 블록이 통째로 일치할 때 성립하므로, 공통 시스템 프롬프트도 32의 배수까지만 재사용됩니다.
해설
블록 크기는 GPU 병렬성과 내부 단편화 사이의 절충입니다. 블록이 너무 작으면 커널이 한 번에 처리할 KV가 적어 GPU를 다 채우지 못하고 블록 테이블도 길어집니다. 블록이 너무 크면 시퀀스당 마지막 블록에 남는 빈칸이 커지고, 블록 단위로만 성립하는 공유의 입도가 거칠어져 재사용이 맞아떨어질 확률이 떨어집니다. 낭비의 상한이 블록 크기에서 1 을 뺀 값이라, 블록을 키운 만큼 시퀀스 하나가 버릴 수 있는 최대 토큰 수도 함께 커집니다. 시퀀스가 블록보다 짧은 워크로드에서는 이 절충이 특히 나쁘게 기웁니다. 블록 크기를 바꿔가며 측정한 실험에서도 긴 대화 트레이스는 16부터 128까지 비슷했지만, 시퀀스가 짧은 트레이스에서는 큰 블록이 성능을 뚜렷하게 떨어뜨렸습니다. 16이 기본값인 이유는 GPU를 효율적으로 쓸 만큼 크면서 대부분의 워크로드에서 내부 단편화가 눈에 띄지 않을 만큼 작기 때문입니다.
이 내용 다시 읽기 →●●●분석과 판단확장구조계산
블록 크기는 16토큰입니다. 같은 시스템 프롬프트로 시작하는 요청 쌍이 둘 있고, 각 쌍은 공통 구간이 놓인 논리 블록을 전부 같은 물리 블록에 매핑해 나눠 쓰는 중입니다. 그래서 그 물리 블록들의 참조 카운트는 모두 2입니다.
쌍 P: 두 요청의 토큰 열이 앞에서 100개까지 같고, 101번째 토큰부터 갈라집니다.
쌍 Q: 두 요청의 토큰 열이 앞에서 96개까지 같고, 97번째 토큰부터 갈라집니다.
이제 네 요청이 각자 갈라지는 첫 토큰의 KV를 씁니다.
이 쓰기로 각 쌍에서 실제로 복사되는 물리 블록은 몇 개입니까?
쌍 P에서 7개, 쌍 Q에서 6개가 복사되어 모두 13개 가 됩니다.
공통 구간이 차지한 블록 수를 그대로 센 값입니다. 100토큰은 블록 7개, 96토큰은 블록 6개를 씁니다. 갈라짐이 공유를 통째로 끝낸다는 전제가 틀렸습니다. 앞쪽 블록들은 이미 확정된 토큰의 K와 V만 담고 있어 양쪽 요청이 읽기만 하므로, 값이 달라질 일이 없어 한 벌로 계속 공유됩니다. 복사가 필요한 자리는 두 요청이 서로 다른 값을 써 넣으려는 블록뿐입니다.
쌍 P와 쌍 Q 양쪽에서 각각 1개씩, 모두 2개가 복사됩니다.
갈라지는 순간 자체를 복사의 방아쇠로 본 답입니다. 복사를 부르는 것은 갈라짐이 아니라 이미 공유 중인 블록 안으로 쓰기가 들어오는 일입니다. 96은 16의 배수라 쌍 Q는 마지막 공유 블록까지 꽉 차 있고, 97번째 토큰은 그 블록이 아니라 아직 매핑이 없는 다음 논리 블록으로 갑니다. 꽉 찬 블록은 이후 아무도 덧쓰지 않으니 복사할 이유가 없습니다.
정답쌍 P에서 1개가 복사되고, 쌍 Q에서는 한 개도 복사되지 않습니다.
100을 16으로 나누면 나머지가 4라, 쌍 P의 마지막 공유 블록은 4칸만 차 있습니다. 101번째 토큰이 그 블록의 다섯 번째 칸으로 들어가는데 참조 카운트가 2이므로 복사가 일어나고, 그 순간 원래 블록의 참조 카운트가 1로 내려가 나머지 한 요청은 제자리에 씁니다. 96은 16으로 나누어떨어지므로 쌍 Q의 공유 블록은 모두 꽉 차 있고, 97번째 토큰은 아직 없던 논리 블록으로 가니 두 요청이 빈 블록을 하나씩 새로 받습니다.
쌍 P에서 2개가 복사되고, 쌍 Q에서는 한 개도 복사되지 않습니다.
갈라지는 두 요청이 각자 한 벌씩 복사한다고 본 값입니다. 먼저 쓴 요청이 새 블록을 받아가면서 원래 블록을 놓아주기 때문에 그 블록의 참조 카운트는 1로 내려가고, 뒤에 쓰는 요청은 더 이상 공유 상태가 아닌 그 블록에 그대로 덧씁니다. 그래서 두 요청이 동시에 갈라져도 실제 복사는 한 번에 그칩니다.
해설
블록 테이블이 논리 블록과 물리 블록 사이에 한 겹의 간접 참조를 놓아준 덕분에, 서로 다른 요청의 블록 테이블이 같은 물리 블록을 가리키는 일이 가능해집니다. 공통 프롬프트의 KV를 한 벌만 두고 여러 요청이 나눠 쓰는 것이 여기서 나옵니다. 복사가 필요해지는 조건은 두 가지가 겹칠 때뿐입니다. 쓰려는 블록의 참조 카운트가 1보다 크고, 그 블록에 아직 빈 칸이 남아 쓰기가 그 안으로 들어갈 때입니다. 그래서 판정은 공통 접두사 길이를 블록 크기로 나눈 나머지 하나로 끝납니다. 나머지가 0이 아니면 마지막 공유 블록이 덜 찬 상태이고 다음 토큰이 그 안으로 들어가니 그 블록 하나가 복사됩니다. 나머지가 0이면 공유 블록이 전부 꽉 차 있어 다음 토큰은 매핑이 아직 없는 논리 블록으로 가고, 각 요청이 빈 블록을 하나씩 새로 받으므로 복사가 아예 생기지 않습니다. 어느 쪽이든 복사되는 양은 블록 하나를 넘지 않고, 이미 꽉 찬 앞쪽 블록들은 읽기만 하는 자리라 끝까지 공유된 채 남습니다. 공유하던 요청이 셋 이상이어도 셈은 같습니다. 먼저 쓴 요청이 새 블록으로 옮겨가며 참조 카운트를 하나씩 낮추다가 마지막 한 요청이 남은 블록을 그대로 물려받으므로, 복사 횟수는 요청 수에서 1을 뺀 만큼입니다.
이 내용 다시 읽기 →