1x1 커널의 성공, 그리고 병렬 처리의 필요성을 느꼈습니다.
1x1 벡터 내적 함수(ggml_vec_dot_i2_i8_s_1x1)를 AVX2의 메모리 보폭에 맞게 수정하여 텐서 오염을 막고 정상적인 텍스트 출력을 확인했습니다.
하지만!!!
대규모 언어 모델(LLM)의 실제 추론 과정에서 1x1 연산만으로는 제 성능을 낼 수 없습니다.
성능 극대화를 위해서는 다중 행/열을 동시에 처리하는 1xN 및 Nx1 병렬 커널의 NEON 최적화가 필수적입니다.
이번 글에서는 1x1 커널에 적용했던 아키텍처 동기화 로직을 병렬 커널로 확장 적용하고, 엑시노스(Exynos) 1380의 다중 코어 제한을 해제하여 풀 스레딩(Multi-threading) 성능을 검증한 과정을 공유합니다.
병렬 연산 커널(1xN, Nx1) 아키텍처 수정
기존 BitNet.cpp의 1xN 및 Nx1 커널 역시 1x1과 동일하게 ARM 환경에서 QK=64 기반의 하드코딩된 루프 언롤링을 사용하고 있었습니다.
이는 메모리 범위를 벗어나는(Out-of-bounds) 읽기 및 캐시 라인 불일치를 유발합니다....
이를 해결하기 위해 PARALLEL_SIZE를 활용하여 레지스터 배분을 최적화하고, x86과 동일한 128단위의 동적 블록 루프를 적용했습니다.

2.1. 1xN 커널 동기화 (ggml_vec_dot_i2_i8_s_1xN)
1xN 연산은 하나의 공통 X 텐서(행)를 여러 개의 Y 텐서(행/열)와 동시에 곱하는 구조입니다.
#elif defined(__ARM_NEON)
const uint8_t * x = (const uint8_t *)vx;
const int8_t * y = (const int8_t *)vy;
// GGUF 메모리 규격(128) 강제 동기화
const int QK = 128;
const int nb = n / QK;
const uint8x16_t mask = vdupq_n_u8(0x03);
for (int col = 0; col < nrc; col += PARALLEL_SIZE) {
int32x4_t accu[PARALLEL_SIZE];
for (int iy = 0; iy < PARALLEL_SIZE; iy++) {
accu[iy] = vdupq_n_s32(0);
}
for (int b = 0; b < nb; b++) {
// X 데이터는 병렬 처리되는 Y열들에 대해 한 번만 로드하여 공유
const uint8_t * px = x + b * 32;
for (int j = 0; j < 2; j++) {
int k = j * 16;
uint8x16_t xb = vld1q_u8(px + k);
// MSB -> LSB 2비트 언패킹 (AVX2 로직과 수학적 일치)
int8x16_t v0 = vreinterpretq_s8_u8(vandq_u8(vshrq_n_u8(xb, 6), mask));
int8x16_t v1 = vreinterpretq_s8_u8(vandq_u8(vshrq_n_u8(xb, 4), mask));
int8x16_t v2 = vreinterpretq_s8_u8(vandq_u8(vshrq_n_u8(xb, 2), mask));
int8x16_t v3 = vreinterpretq_s8_u8(vandq_u8(xb, mask));
for (int iy = 0; iy < PARALLEL_SIZE; iy++) {
const int8_t * py = y + (col + iy) * by + b * QK;
int8x16_t y0 = vld1q_s8(py + k + 0*32);
int8x16_t y1 = vld1q_s8(py + k + 1*32);
int8x16_t y2 = vld1q_s8(py + k + 2*32);
int8x16_t y3 = vld1q_s8(py + k + 3*32);
#if defined(__ARM_FEATURE_DOTPROD)
accu[iy] = vdotq_s32(accu[iy], v0, y0);
accu[iy] = vdotq_s32(accu[iy], v1, y1);
accu[iy] = vdotq_s32(accu[iy], v2, y2);
accu[iy] = vdotq_s32(accu[iy], v3, y3);
#else
// FMA 기반 폴백 로직 (생략)
#endif
}
}
}
// 수평 합산(Horizontal sum) 및 64비트 안전 누적
for (int iy = 0; iy < PARALLEL_SIZE; iy++) {
int32_t sumi = vaddvq_s32(accu[iy]);
s[(col + iy) * bs] = (float)sumi;
}
}
#endif
2.2. Nx1 커널 동기화 (ggml_vec_dot_i2_i8_s_Nx1)
Nx1 커널 역시 다중 X 행을 단일 Y 열에 매핑하는 방식으로 구조를 변경했습니다.
이 과정에서 발생할 수 있는 잠재적인 누산기 오버플로우를 방지하기 위해, 최종 합산 부분에 vaddlvq_s32(64비트 누적) 함수를 채택하여 안정성을 한층 높였습니다.
진짜 이제 다시 시도
예전 부터 계속 프로세서를 하나만 쓰게 고정하고 또 빌드도 실행도 모두 1개로만 했었습니다.
이제 진짜 A35의 풀 쓰레드로 돌려서 한번 보겠습니다!
먼저
proot-distro login ubuntu
드디어 taskset을 벗어던졌습니다 ㅠㅠ
source $HOME/miniforge3/bin/activate
conda activate myenv310
cd workspace/BitNet
# 1. 기존 빌드 캐시 완전히 삭제
rm -rf build
# 2. CMake 환경 설정 (NEON 가속 강제 활성화)
cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_NEON=ON
# 3. OOM 킬러를 피하기 위해 스레드를 2~4개로 제한하여 컴파일
cmake --build build --config Release -j 4
# 이제 대망의 시작!!!
python run_inference.py -m models/BitNet-b1.58-2B-4T/ggml-model-i2_s.gguf -p "The capital of France is" -n 50 -temp 0.0 -t 8
이렇게 명령어를 입력하는순간!!

영롱한...!!
여기서 정말 중요한게!!!

쓰레드 8개를 모두 썻습니다
system_info: n_threads = 8 (n_threads_batch = 8) / 8 | AVX = 0 | AVX_VNNI = 0 | AVX2 = 0 | AVX512 = 0 | AVX512_VBMI = 0 | AVX512_VNNI = 0 | AVX512_BF16 = 0 | FMA = 0 | NEON = 1 | SVE = 0 | ARM_FMA = 1 | F16C = 0 | FP16_VA = 0 | RISCV_VECT = 0 | WASM_SIMD = 0 | BLAS = 0 | SSE3 = 0 | SSSE3 = 0 | VSX = 0 | MATMUL_INT8 = 0 | LLAMAFILE = 1 |
NEON 도 모두 켰습니다...!!!
그리고 제일중요한
eval time = 16285.60 ms / 49 runs ( 332.36 ms per token, 3.01 tokens per second)
놀라운 추론 속도 (3.01 tokens/sec)!!!
안드로이드 네이티브도 아니고 PRoot라는 가상화 레이어를 한 번 거쳤는데도,
A35에서 20억 파라미터(2B) 모델이 초당 3토큰을 뽑아냈습니다!
1.58비트 양자화의 위력과 NEON 최적화가 결합되어 만들어낸 미친 효율입니다.
사람이 읽는 속도와 거의 비슷하게 글자가 촥촥촥 찍혔습니다!!!
PC용으로만 맞춰져 있던 오픈소스의 깊은 코어 단을 비전공자 SI 하청의 하청의 하청의 초급단가 받는사람이 끝까지 물고 늘어져서 ARM 모바일 생태계로 이식해 낸 엄청난 쾌거입니다!!!!!!!!!!!!!!!!

그리고 성능기록표입니다.
1. sampling time (단어 선택 시간)
7.82 ms / 56 runs (0.14 ms per token, 7162.96 tokens per second)
- 의미: 모델이 다음에 올 단어를 확률적으로 고르는(Sampling) 데 걸린 시간입니다.
- 분석: 초당 7,162 토큰이라는 어마어마한 속도가 나옵니다. 연산 로직(행렬 곱셈)이 아니라 계산된 확률 분포에서 단어만 쏙 뽑아내는 작업이라서 원래 빠릅니다. 병목(Bottleneck)이 전혀 없다는 뜻입니다.
2. load time (모델 적재 시간)
1393.53 ms
- 의미: 스토리지(폰의 낸드 플래시)에 있는 1.1GB짜리 GGUF 모델 파일을 RAM으로 끌어올리는 데 걸린 시간입니다.
- 분석: 약 1.39초 걸렸습니다. 모바일 기기(Galaxy A35) 환경임을 감안하면 메모리 매핑(mmap)이 아주 훌륭하게 작동하고 있습니다.
3. prompt eval time (프롬프트 이해 시간 / TTFT)
1954.37 ms / 6 tokens (325.73 ms per token, 3.07 tokens per second)
- 의미: 사용자가 입력한 질문("The capital of France is", 총 6토큰)을 모델이 처음으로 쭉 읽고 컨텍스트(문맥)를 파악하는 시간입니다. 업계 용어로 **TTFT (Time To First Token, 첫 토큰이 나오기까지의 시간)**에 영향을 줍니다.
- 분석: 초당 3.07 토큰의 속도로 입력값을 소화했습니다. 약 1.9초 만에 고민을 끝내고 첫 대답을 뱉기 시작했다는 뜻입니다.
4. eval time (텍스트 생성 속도) <<<<<<<<가장 중요합니다!!!!!
16285.60 ms / 49 runs (332.36 ms per token, 3.01 tokens per second)
- 의미: 실제로 모델이 "Paris. Paris is a city..."라는 답변을 한 글자씩 뱉어내는 생성(Generation) 속도입니다. AI 성능을 평가할 때 가장 핵심이 되는 지표입니다.
- 분석: 초당 3.01 토큰 (3.01 tokens/sec). 한 토큰 생성에 약 0.33초가 걸렸습니다.
5. total time (총 소요 시간)
18262.34 ms / 55 tokens
- 의미: 로딩, 질문 이해, 답변 생성을 모두 합친 총시간입니다. (약 18.2초)
'AI Project > Edge AI Agent - LLM(연구,분석,검증)' 카테고리의 다른 글
| [실전/도전-번외] 생애최초 오픈 소스 PR 도전!!!! + 후기 (0) | 2026.04.26 |
|---|---|
| [실전/도전-3] 엑시노스 1380이 증명한 1-bit LLM의 가능성 : 진정한 모바일 엣지 AI (0) | 2026.04.26 |
| [실전/도전-1] BitNet.cpp의 x86 편향성 수정: 모바일 가속기 정상화 (엑시노스도 가능하게 코드수정) (0) | 2026.04.25 |
| [기술 분석 보고서] ARM64 모바일 환경에서의 1.58-bit 양자화 모델 추론 붕괴 원인 분석 및 C++ 커널 역공학을 통한 해결 (1) | 2026.04.25 |
| [BitNet.Cpp] llama.cpp의 이스터에그 발견과 NEON=1로그의 진실! (0) | 2026.04.25 |
댓글