이전부터 EVO-X2의 메모리 128GB를 큰 모델을 구동하는 데 사용하는 것이 아니라, 모델 자체를 가능한 한 작게 만들어 컨텍스트 길이에 전념하면 어떻게 될지 궁금했다.
내 사용 방식이 잘못되었을 수도 있지만, '긴 글에서 특정 내용을 검색'할 때 RAG(Retrieval-Augmented Generation)로는 잘 작동하지 않아, 긴 글 전체를 컨텍스트로 모델에 로드하는 것이 더 좋은 경우가 많다. 이런 방식으로 사용하다 보니, 금방 컨텍스트 오버(context over)가 발생하여 에러가 나곤 한다.
EVO-X2 × Qwen3.8-Flash-Next 외전 2에서는 작은 모델인 Qwen3.5-2B UD-Q4_K_XL을 사용하여, 컨텍스트 길이 1M까지의 속도와 메모리 사용량을 측정했다. 2M에도 도전했지만, Vulkan의 pinned memory allocation 경고가 반복적으로 발생하며 극도로 느려졌기 때문에 중간에 중단했다.
이번에는 그 원인을 조사하여 회피하고, 설정 컨텍스트 4M(4,194,304 tokens), 실제 입력 3,774,852 tokens까지 늘려보았다. 속도와 메모리 사용량뿐만 아니라, 긴 입력 내용 중에서 간단한 정보를 추출할 수 있는지 여부도 조사해 보았다.
- Qwen3.5-2B UD-Q4_K_XL로,
컨텍스트 길이 설정 4M, 실제 입력 3,774,852 토큰의 로드 및 답변 생성이 가능했다 -
로드와 생성이 가능하다는 것과, 생성 내용이 정확하다는 것은 별개의 문제이며, 이번 내용 테스트에서는
컨텍스트 1M까지는 합격, 1.5M부터 답변 형식에 이상 발생, 2M과 4M에서는 불합격이었다 -
컨텍스트 4M의 처리 시간은
16시간 54분이었다. PP(Prompt Processing)는 62.05 tok/s, TG(Token Generation)는 8.27 tok/s까지 저하되었다 -
컨텍스트 4M에서도 메모리에는 여유가 있었다. 더 늘리는 것을 중단한 주된 이유는 처리 시간 및 답변 품질, 그리고 Vulkan의
단일 버퍼/단일 메모리 할당 약 2GiB의 제약 때문이었다.
| 항목 | 내용 |
|---|
| PC | GMKtec NucBox EVO-X2 |
| ... | Unsloth Qwen3.5-2B-GGUF, UD-Q4_K_XL |
| 모델 용량 | llama.cpp 표시로 1.24GiB, 파라미터 수 약 1.88B |
| 모델의 표준 컨텍스트 | 262,144 tokens |
| 추론 | llama.cpp Vulkan (후술할 2GiB guard 대책 개조판) |
| ... | |
| 이번 실험은 Vulkan 환경에서의 것이며, ROCm에서 동일한 4M 결과가 나올지는 검증하지 않았다. | |
일반적으로 모델에는 학습 및 설계상 예상되는 컨텍스트 길이가 있다. 이번 Qwen3.5-2B의 표준 길이는 262,144 tokens이다. 이보다 길게 사용하기 위해 사용한 것이 **YaRN (Yet another RoPE extensioN)**이라는 RoPE(Rotary Position Embedding) 확장 방식이다.
RoPE는 Attention에서 토큰의 위치 관계를 처리하는 메커니즘이다. YaRN은 그 위치 정보 처리를 조정하여, 원래 길이보다 긴 시퀀스를 다룰 수 있게 한다. 이번 테스트에서는 추가 학습이 아닌, llama-cli.exe의 추론 시 설정을 통해 확장했다.
| 설정 컨텍스트 | 원래 262,144 tokens로부터의 확장 배율 |
|---|
| 32K / 262K | 확장 없음 |
| ... | |
| 다만, 입력 가능한 길이를 설정할 수 있다는 것과, 그 길이에서의 답변 품질은 별개의 문제이다. 위치 정보만 확장되었다고 해서 모델의 검색 능력이나 지시 추종 능력이 보장되는 것은 아니다. | |
각 컨텍스트 길이별 속도 측정 결과는 다음과 같다. 입력은 후술하는 단순 검색 테스트를 사용했다.
아래에서 Prompt Processing(입력을 로드하는 처리)을 PP로, Token Generation(답변을 생성하는 처리)을 TG로 표기한다. 속도의 단위는 둘 다 tok/s이며, 높을수록 빠르다. 경과 시간은 실행 시작부터 종료까지의 실시간(wall time)이다.
| 설정 ctx | 실제 입력 tokens | KV | -ub |
경과 시간 | PP (tok/s) | TG (tok/s) |
|---|---|
| 32K | 29,488 | q8_0 | 512 | 14초 | 2,765.94 | 109.87 |
| ... | 4M |
3,774,852 |
q4_0 |
256 |
16시간 54분 |
62.05 |
8.27 |
모델이 작기 때문에 32K에서는 PP와 TG 모두 Qwen3.8-Flash-Next로 측정했을 때 본 적 없는 빠른 수치가 나왔지만, 약 1M부터 속도가 느려지기 시작했고, 4M에서는 PP가 두 자릿수까지, TG는 한 자릿수까지 떨어져 약 17시간이 걸렸다.
이는 긴 KV 캐시(KV cache)를 참조하는 처리가 늘어나기 때문이며, 모델 본체가 1.24GiB로 작더라도 장문 처리가 가볍지는 않다는 것을 의미한다.
참고로 도중에 KV 캐시의 양자화(quantization)를 q8_0에서 q4_0으로 변경했고, 4M일 때만 ubatch를 512에서 256으로 변경했다 (이유는 후술). 따라서 이 표의 속도 차이를 전부 '컨텍스트 길이만의 영향'이라고 말할 수는 없다.
밤에 배치(batch)로 실행하더라도 소요 시간의 허용 범위가 약 10시간 정도이므로, 17시간은 현재로는 실용성이 제로다. 설령 이 부분을 고속화할 수 있다 하더라도, 다음 답변 품질 문제가 남아있다.
속도만 측정해서는 '장문을 읽어 들인 후에 제대로 답변할 수 있는지'를 알 수 없다. 그래서 긴 입력 속에 5개의 표지(目印)를 심고, 마지막에 그 표지에 대응하는 코드를 질문하는 문제를 만들었다.
구체적으로는 다음과 같은 문제로, 복잡한 추론은 요구하지 않는다.
입력의 초반: <<< MEMORY TARGET ALPHA >>> / CODE=MEM-422777
입력의 후반: <<< MEMORY TARGET ECHO >>> / CODE=MEM-509065
입력의 말단: <<< LOCAL CONTROL >>> / CODE=CTL-445570
...
5개의 표지는 입력 본문의 약 5%, 25%, 50%, 75%, 95% 위치에 배치했다. Q6은 질문 직전 근처에 있는 정보만 찾으면 정답을 맞힐 수 있는 local control로 설정했다.
Q1~Q5는 '설문 번호'와 '완벽한 CODE'가 일치하는 것을 정답 조건으로 했다. 예를 들어, 올바른 코드가 출력 중에 등장하더라도 다른 Q 번호에 연결되어 있으면 오답 처리한다.
| 설정 ctx (KV) | Q1~Q5 채점 결과 | 정확한 완전 CODE의 등장 수 | Q6 | 관찰된 점 |
|---|
| 32K (q8_0) | 5/5 | 5/5 | PASS | 완벽 정답 |
| 262K (q8_0) | 5/5 | 5/5 | PASS | 완벽 정답 |
| 262K (q4_0) | 5/5 | 5/5 | PASS | KV를 q4_0으로 해도 완벽 정답 |
| 512K (q4_0) | 5/5 | 5/5 | PASS | YaRN ×2로도 완벽 정답 |
| 1M (q4_0) | 5/5 | 5/5 | PASS | YaRN ×4로도 완벽 정답 |
| 1.5M (q4_0) | | 5/5 | FAIL | Q6의 수치는 맞지만 접두사가 틀림 |
| 2M (q4_0) | | 5/5 | FAIL | 코드는 나오지만 설문과의 대응 및 형식이 무너짐 |
| 4M (q4_0) | | 0/5 | FAIL | 자릿수가 빠진 동일 코드를 6개 질문에 반복 출력 |
단순히 정답률이 점차 떨어지는 것이 아니라, 망가지는 방식이 단계별로 달랐다.
Q1~Q5는 모두 정답이었다. 반면, 말단 근처의 Q6에서 정답인 CTL-445570이 MEM-445570으로 바뀌었다. 숫자는 맞지만, 접두사만 다르다.
local control 쪽이 질문과 정답이 적혀있는 위치가 가깝기 때문에 쉬울 것이라 생각했지만, 그렇지 않은 것 같다.
답변 과정 중에 올바른 완전 CODE가 5가지 모두 존재했다. 다만 Q1=... 같은 설문 번호가 사라지고 순서나 형식도 무너졌기 때문에, 채점 결과는 0/5였다. Q6 역시 불합격이었다.
이것은 '2M에서는 해당 부분을 전혀 참조할 수 없다'와는 약간 다르다. 적어도 올바른 정보의 조각은 출력에 나타나지만, 그것을 지정된 대로 대응시켜 답변하지 못하고 있는 상태이다.
4M에서는 Q1의 정답 MEM-422777에서 마지막 한 자리가 빠진 MEM-42277를 Q1~Q6에 반복 출력했다. 올바른 완전 CODE는 0가지였고, Q6 역시 불합격이었다.
Q1=MEM-42277
Q2=MEM-42277
...
...
이것은 2M보다 명확하게 망가진 정도가 크다. 다만, 이 출력만으로는 모델 내부에서 정보가 완전히 손실되었는지, 아니면 읽어오기나 지시 추종에 실패했는지까지는 알 수 없다.
이번 테스트에서는 모든 문제를 통과할 수 있는 것은 1M까지만 가능했다. 하지만 이것이 Qwen3.5-2B의 일반적이고 절대적인 '실용 상한선'은 아니다. 모델의 검색 능력, 입력 데이터 구성 방식, 질문 순서, YaRN 배율 등이 영향을 미치기 때문이다.
만약 같은 내용으로 질문 순서만 역순으로 바꿔 테스트해 보면, 32K와 512K에서도 채점 결과가 4/5인 경우가 있었습니다. 32K에서도 모든 문제를 맞히지 못하는 경우도 있기 때문에, 단순히 긴 컨텍스트 길이로 만든 것만의 문제는 아니라고 할 수 있습니다.
| 설정 ctx (KV) | Vulkan 컨텍스트 할당 (MiB) | 추론 프로세스 GPU 피크 (GiB) | Windows 전체 Commit 피크 (GiB) | 최소 여유 RAM (GiB) |
|---|
| 32K (q8_0) | 223 | 2.15 | 11.87 | 23.94 |
| ... | 4M (q4_0) | 13,843 | 17.30 | 31.36 |
- Vulkan 컨텍스트 할당: llama.cpp의 메모리 내역 중
context로 분류된 용량입니다. 모델 본체나 모든 임시 버퍼의 합계는 아닙니다.
- GPU 피크: 자체 제작한 모니터링 스크립트로 얻은
TargetGPUTotalCommitted_GiB 값입니다. 추론 프로세스에 대해 Windows GPU 관련 카운터에서 가져온 값입니다.
- Commit 피크: Windows 시스템 전체의 커밋량입니다. GPU 피크와 더해서 128GB 중 사용량을 계산하는 값이 아닙니다.
- 최소 여유 RAM: Windows 측에서 관측한 여유 RAM의 최소값입니다. UMA 96GB 설정으로 인해 Windows CPU 측에 보이는 용량이 약 32GB인 구성에서의 값입니다.
4M일 때, Vulkan 컨텍스트 할당은 13,843MiB(약 13.5GiB)였고, 프로세스의 GPU 피크는 17.30GiB, 최소 여유 RAM은 18.14GiB였습니다. 적어도 이번 4M 완료 시점에서는 총 메모리 용량을 다 소진하지 않았습니다.
원래 계획으로는 128GB 메모리의 한계까지 컨텍스트 길이를 늘려보고 싶었지만, 시간이 너무 오래 걸리고 출력 품질에도 문제가 생겼으며, Vulkan의 단일 버퍼 2GiB 제한을 회피하는 방법을 고민할 필요성을 느끼지 못하여, 메모리에 여유가 있음에도 불구하고 4M에서 테스트를 종료했습니다.
이전에는 컨텍스트 길이를 2M으로 설정하고, 1M마다 급격히 느려져 중간에 멈춘 적이 있습니다.
이번에도 처음에 같은 조건으로 시도했을 때는 동일한 현상이 재현되었습니다.
구체적으로는 다음과 같은 경고가 빈번하게 발생했습니다.
ggml_vulkan: Failed to allocate pinned memory (Requested buffer size exceeds device buffer size limit: ErrorOutOfDeviceMemory)
1,048,576 → 1,067,008의 18,432 토큰을 처리하는 데 약 4시간 40분이 걸렸으며, 이 구간에서는 약 1.1tok/s에 머물렀습니다. 이대로 계속 진행하면 언제 끝날지 알 수 없었기 때문에 일단 중단하고 원인을 조사해 보았습니다.
조사한 결과, Flash Attention용 F16 KQ mask가 2GiB급이 되면서 그래프 분할(graph splitting) 및 폴백(fallback), 그리고 pinned-memory 경고가 발생하여 극심한 저속화의 원인이 된 것으로 파악되었습니다.
Attention에서는 Query (Q)가 어떤 Key (K)를 참조할 수 있는지 제어하기 위해 KQ mask라는 마스크를 사용합니다. 예를 들어, 미래 토큰을 보지 못하게 하는 제한이나 패딩 등을 나타냅니다. 이번 Flash Attention 경로에서는 이 마스크의 F16 작업 영역이 처리 중인 마이크로 배치 크기 (-ub)와 참조하는 KV 길이가 증가함에 따라 거대해지고 있었습니다.
그래서 -ub 1024에서 -ub 512로 낮추자 마스크가 작아지면서 극단적인 속도 저하 현상은 발생하지 않는다고 생각했지만, 그 직후 다른 오류로 인해 멈췄습니다.
ggml_vulkan: Device memory allocation of size 2148532224 failed.
ggml_vulkan: Requested buffer size exceeds device buffer size limit: ErrorOutOfDeviceMemory
로그상으로는 입력이 약 1,048,576 토큰까지 진행된 후 decode() failed 상태였습니다.
처음의 극심한 저속화와 두 번째의 ErrorOutOfDeviceMemory 모두 2GiB 정도의 메모리 할당에서 걸리는 현상이었습니다. 시스템 전체적으로 RAM도 VRAM도 여유가 있는데 왜 막히는지 조사해 보니, Vulkan의 메모리 할당에 제약이 있었던 것입니다.
Vulkan에서는 장치별로 maxBufferSize나 maxMemoryAllocationSize와 같은 제약이 있습니다. 후자는 전체 힙(heap)에 여유 공간이 있더라도 단일 메모리 할당 크기에 상한선이 있다는 것을 의미합니다. 수치는 구현이나 드라이버에 따라 다르므로, 'Vulkan 사양이 모두 2GiB까지'라는 뜻은 아니지만, EVO-X2의 제 환경에서는 2GiB 제한이 있었습니다.
ErrorOutOfDeviceMemory의 원인을 조사해 보니, 사용하던 llama.cpp에는 양자화된 KV 캐시를 F16으로 전개하는 최적화가 적용되어 있었고, 이로 인해 K와 V의 총합을 한 번에 처리해야 하는 임시 영역이 **2,148,532,224 bytes (2,049MiB)**가 필요했습니다. 이는 2GiB보다 1MiB가 더 큰 수치였습니다.
※KV 캐시는 과거에 처리한 토큰의 Key와 Value를 저장해 두는 영역입니다. 입력이 길어질수록 증가합니다.
그래서 K+V 임시 영역의 총합이 상한을 초과할 경우 F16 전개 최적화를 사용하지 않고, 양자화된 KV를 처리하는 Flash Attention 경로로 전환하도록 llama.cpp를 개조했습니다.
어쩌다 이런 상황에서 llama.cpp를 개조해야 한다는 생각이 자연스럽게 떠오르는 것을 보면 제가 이 깊은 늪에 빠져 있는 건 아닌지 걱정되기도 하지만, 실제로 구현하는 것은 Chappy님(チャッピー様)이시니 괜찮습니다.
여기까지의 -ub 1024 -> 512 조정과 KV 캐시 F16 최적화 회피 덕분에 컨텍스트 2M까지는 작동하게 되었지만, 4M까지 늘리려고 하니 다시 2GiB 제한에 걸릴 가능성이 높았습니다.
그래서 KV 캐시의 양자화를 Q8_0에서 Q4_0으로 변경하고, 게다가 -ub를 256까지 줄였습니다. 즉, 2GiB 제한을 초과하지 않도록 파라미터를 조정한 것이지, 2GiB 제한 자체를 넘기게 만든 것은 아닙니다.
2M 구간의 초기에서 극도로 느려졌을 때와, ub 변경 + llama.cpp 개조로 정상 작동했을 때, 그리고 4M에서 정상 작동했을 때의 16,384 토큰 간격별 PP 속도를 그래프화해 보았습니다(Chappy님께 그래프를 그려달라고 요청했습니다).
이 그래프는 stderr.log의 cached n_tokens와 경과 시간을 통해 16,384 토큰 간격별 PP 속도를 계산하여 겹쳐 놓은 것입니다.
빨간색은 느려져 중단한 2M 구간, 파란색은 수정 후 완주한 2M 구간, 초록색은 나중에 완주한 4M 구간입니다. 약 1M 지점의 점선은 속도 저하가 시작된 근처를 나타냅니다. 빨간색이 급락한 것은 수동으로 중단했기 때문이 아니라, 중지하기 직전 속도가 이 정도로 떨어졌다는 것을 의미합니다.
가로축은 설정된 ctx가 아닌 실제로 처리된 토큰 수이며, 세로축은 큰 속도 차이를 보여주기 위해 로그 스케일을 사용했습니다.
※4M은 KV q4_0・ub256, 2M은 KV q8_0으로, 입력 테스트 문제 자체도 다르므로 단순 성능 비교는 어렵습니다.
작은 모델을 사용하면 128GB의 EVO-X2에서도 4M 컨텍스트 처리가 가능하다는 것을 알았습니다. 약 377만 토큰을 16시간 54분 동안 로드하고 답변 생성까지 도달했습니다.
반면, 단순 검색 문제에서도 1.5M에서는 식별자 오인식이, 2M에서는 대응 관계 및 형식의 붕괴가, 4M에서는 불완전한 코드 반복이라는 차이가 나타났습니다. 적어도 이번 조건 하에서는 '메모리에 들어오니 실용적으로 사용 가능하다'고 말할 수 없습니다.
이번 실험을 마친 이유는 메모리 잔량이 아니라, 소요 시간, 검색 품질, 그리고 Vulkan의 단일 버퍼 2GiB 제약 대응 부담 때문이었습니다. 4M보다 더 큰 용량 한계에 대해서는 미측정이라 최대 몇 M까지 작동할지는 알 수 없습니다.
메모리에는 여유가 있었기 때문에, 조금 더 큰 모델로 시도해 보면 성능이 좀 더 나올 수도 있겠지만, Vulkan의 단일 버퍼 2GiB 제약에 걸리는 것은 변함없습니다.
역시 무리하게 컨텍스트를 늘리기보다는, RAG(Retrieval-Augmented Generation)를 더 고안해서 제대로 쓸 수 있게 만드는 편이 나을까요??
# 4M의 주요 설정 예시
& $exe -m $model -f $prompt `
-c 4194304 -ngl 999 -n 128 -t 4 `
...
여기서 -c는 모델에 확보할 컨텍스트 공간을 의미하며, --rope-scale 16은 원래의 262,144 대비 YaRN의 배율입니다. --override-kv도 병용하여 모델 측의 컨텍스트 길이 메타데이터를 확장했습니다.
긴 문장과 질문으로 구성된 입력을 준비할 때, 일반적인 문장을 대량으로 나열하면 질문 자체가 어려운 것인지, 너무 길어서 실패한 것인지 구별하기 어렵습니다. 그래서 기계적으로 생성한 배경 기록(background record)과 명확한 정답 코드를 가진 표식(marker)을 사용했습니다.
- 배경 기록을 대량으로 생성하여 설정 컨텍스트의 약 90%가 되도록 한다.
- 전체의 약 5%, 25%, 50%, 75%, 95% 지점에 각각 고유한
MEMORY TARGET 레코드와 CODE=MEM-xxxxxx를 배치한다. 끝에는 LOCAL CONTROL과 CODE=CTL-xxxxxx를 위치시킨다.
- 마지막 질문 Q1~Q5에서 5개
MEMORY TARGET에 대응하는 CODE를 묻고, Q6에서 LOCAL CONTROL을 묻는다. temperature 0, 일반적인 질문 순서, 고정 seed로 실행하며, 질문 ID와 CODE의 완전 일치 여부를 채점한다.
Q6는 원거리 검색(long-range search)을 하지 않아도 얻을 수 있어야 하는 정보를 찾는 대조 문제이다. 여기서 오류가 발생하면 장거리 검색의 성적 해석이 어려워지므로, **실패(FAIL)할 경우 전체를 무효(INVALID)**로 처리한다.
사실, 좀 더 어려운 문제로 정답과 유사한 가짜 후보(distractor)를 배치하고 여러 조건 중 선택하게 하는 방안도 시도했다. 그러나 32K 지점에서도 문제 구성 방식에 따라 실패가 발생했기 때문에, '길이에 따른 변화' 비교에는 우선 단순한 테스트를 사용하기로 결정했다.
- CPU / APU: Ryzen AI Max+ 395 (Windows 표시 3.00GHz)
- GPU: AMD Radeon 8060S Graphics (
gfx1151)
- RAM: 128GB UMA, Frame Buffer 96GB, Windows CPU 측에서 보이는 RAM 약 32GB
- OS: Windows 11 Pro 25H2, Build 26200.9168, Windows Feature Experience Pack 1000.26100.344.0
- Model:
Qwen3.5-2B-UD-Q4_K_XL.gguf, 1.24GiB, 약 1.88B parameters, 표준 ctx 262,144 - llama.cpp: upstream bed0a856606ee4a24a164066f73d2379447033f5의 개조판 - Backend: Vulkan, SDK 1.4.357.0, LLVM / Clang 20.1.8