오목 엔진 Rapfi 해부 Part 4: 직접 빌드해서 돌려 보기
이 글은 Claude Opus 5.5 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
Part 3 에서 Rapfi 의 내부를 소스 코드로 봤습니다. 이번 편은 직접 돌려 봅니다. 아래의 모든 출력은 2026-09-24 에 Apple M1 Max (macOS, Apple clang 17) 에서 master 커밋 3c94c2a 를 빌드해 실제로 실행한 결과 입니다. 긴 출력은 줄였고, 줄인 곳은 ... 로 표시했습니다.
1. 빌드와 준비#
1.1 받을 것#
Windows 라면 공식 릴리스 에 CPU 별 실행 파일과 GUI 가 함께 들어 있어 빌드할 필요가 없습니다. macOS·Linux 이거나 최신 master 를 쓰려면 직접 빌드합니다. 필요한 것은 C++17 컴파일러(README 는 Clang 을 권합니다)와 CMake 입니다.
git clone https://github.com/dhbloo/rapfi.git
cd rapfi
git submodule update --init Networks # 가중치와 예제 설정
Networks 서브모듈이 rapfi-networks 입니다. 가중치는 CC0 라이선스입니다.
1.2 빌드#
CPU 에 맞는 CMake preset 을 고릅니다. cmake --list-presets 로 목록을 볼 수 있습니다.
"x64-clang-Native" "x64-clang-AVX2" "x64-clang-AVX512"
"x64-clang-AVX2-ST" - x64 SingleThreaded AVX2 (Gomocup ver)
"arm64-clang-Native" "arm64-clang-NEON" "arm64-clang-NEON-DOTPROD"
...
Apple Silicon 에서는 arm64-clang-Native 입니다.
cd Rapfi
cmake --preset arm64-clang-Native
cmake --build build/arm64-clang-Native -j 8
결과물은 build/arm64-clang-Native/pbrain-rapfi 입니다. 이름이 pbrain- 으로 시작하는 것은 Piskvork 프로토콜 엔진의 관례입니다.
1.3 실행 디렉터리 구성#
Rapfi 는 실행한 디렉터리(또는 실행 파일 옆)에서 config.toml 을 찾고, 설정이 가리키는 가중치 파일을 불러옵니다. 한 곳에 모아 둡니다.
mkdir ~/rapfi-run && cd ~/rapfi-run
cp <rapfi>/Rapfi/build/arm64-clang-Native/pbrain-rapfi .
cp <rapfi>/Networks/config-example/config.toml .
cp <rapfi>/Networks/classical/*.bin .
cp <rapfi>/Networks/mix9svq/*.lz4 .
config.toml
mix9svqfreestyle_bsmix.bin.lz4
mix9svqrenju_bs15_black.bin.lz4
mix9svqrenju_bs15_white.bin.lz4
mix9svqstandard_bs15.bin.lz4
model210901.bin
model220723.bin
pbrain-rapfi
신경망 가중치는 룰마다 하나씩이고, 렌쥬만 흑용·백용이 따로 있습니다. config.toml 을 찾지 못하면 엔진은 신경망 없는 내장 설정 으로 조용히 돌아가 훨씬 약해지므로, 첫 줄 출력에서 설정을 읽었는지 확인하는 습관이 좋습니다.
1.4 벤치마크#
./pbrain-rapfi bench
MESSAGE Load config from config.toml
MESSAGE Evaluator set to mix9svq.
MESSAGE ==========Move Bench==========
MESSAGE Total Time (ms): 460
MESSAGE Moves/s: 4347760
MESSAGE =========Search Bench=========
MESSAGE Total Time (ms): 19924
MESSAGE Nodes: 4391204
MESSAGE Nodes/s: 220397
MESSAGE Hash: e240ab16
bench 는 자유룰·표준·렌쥬 국면 10개를 깊이 19까지 고정 탐색합니다. 한 스레드로 초당 약 22만 노드 였습니다. 마지막 Hash 는 탐색 결과의 서명이라 빌드가 같은 동작을 하는지 비교하는 데 씁니다.
2. 첫 대화: Piskvork 프로토콜#
엔진은 아무 인자 없이 실행하면 Piskvork 프로토콜로 stdin 의 명령을 기다립니다. 셸 파이프만으로도 대화할 수 있습니다.
(printf 'START 15\nINFO timeout_turn 1000\nINFO rule 4\nBEGIN\n'; sleep 1
printf 'TURN 7,8\n'; sleep 2; echo END) | ./pbrain-rapfi | grep -v '^MESSAGE Depth'
MESSAGE Load config from config.toml
MESSAGE Evaluator set to mix9svq.
OK
MESSAGE mix9svq nnue: load weight from mix9svqrenju_bs15_black.bin.lz4
MESSAGE mix9svq nnue: load weight from mix9svqrenju_bs15_white.bin.lz4
MESSAGE mix9svq nnue: weight loaded in 49ms
7,7
MESSAGE OptiTime 873ms | MaxTime 970ms
MESSAGE Speed 244K | Depth 17-31 | Eval 386 | Node 237K | Time 973ms
6,8
명령과 응답을 하나씩 보면 다음과 같습니다.
| 보낸 명령 | 뜻 | 엔진의 답 |
|---|---|---|
START 15 | 15x15 판으로 시작 | OK |
INFO timeout_turn 1000 | 한 수에 1초 | (답 없음) |
INFO rule 4 | 렌쥬 룰 | (답 없음) |
BEGIN | 엔진이 흑으로 첫 수를 두라 | 7,7 (천원, 탐색 없이) |
TURN 7,8 | 상대(백)가 7,8 에 뒀다 | 6,8 |
END | 종료 |
좌표 는 0부터 시작하는 x,y 입니다. Rapfi 의 로그(MESSAGE)는 같은 자리를 알파벳 열 + y+1 행으로 적어서, 7,7 은 H8, 6,8 은 G9 입니다. 이 글의 도해도 이 표기를 따르고 행 번호를 아래에서 위로 매깁니다. 렌쥬 입문 시리즈 의 도해와 같은 규약입니다.
INFO rule 값은 Rapfi 에서 0 = 자유룰, 1 = 표준(정확히 다섯), 4 = 렌쥬, 5 = 자유룰+SWAP1, 6 = 자유룰+SWAP2 입니다 (command/gomocup.cpp:212-226). Piskvork 명세의 rule 은 비트마스크(1 = 정확히 다섯, 2 = 연속 대국, 4 = 렌쥬, 8 = Caro)인데, Rapfi 는 값 자체로 분기하므로 조합 값을 보내면 안 됩니다.
흑 h8, 백 h9 다음에 엔진이 둔 g9 는 무엇일까요. 렌쥬 입문 Part 4 에서 본 대로 i9 와 g9 는 좌우 대칭인 같은 주형, 花月(화월) 입니다. 옛 RIF 룰 기준 흑 필승으로 꼽히는 주형을 엔진도 1초 만에 골랐습니다. 오프닝 규정이 없는 자유 렌쥬라서 가능한 선택입니다.
3. 탐색 로그 읽기#
자유룰에서 백 입장으로 3초를 줘 봤습니다(INFO rule 0, INFO timeout_turn 3000, BEGIN 후 TURN 7,8).
MESSAGE OptiTime 2673ms | MaxTime 2970ms
MESSAGE Depth 2-3 | Eval 682 | Time 0ms | G10 F10
MESSAGE Depth 3-4 | Eval 505 | Time 1ms | G10 F10 G8
...
MESSAGE Depth 12-23 | Eval 439 | Time 64ms | G10 F10 F9 E8 G7 I9 G8 G9 J8 I8 J9 J7 I6 H7
MESSAGE Depth 13-21 | Eval 489 | Time 97ms | G10 G8 I10 H10 G9 I7 H11 I12 G12 F13 G11 G13 E11 F11 F10 D12 E9
...
MESSAGE Depth 18-32 | Eval 509 | Time 1125ms | G10 G8 F7 F9 G9 F10 H7 G7 G6 H5 J6 I7 I5 H4 I6 H6 J8 J9 K7 L8 J5 K4 L6
MESSAGE Depth 19-43 | Eval 530 | Time 2484ms | G10 G8 F7 F10 G7 F6 I7 H7 I6 J6 I9 I8 J9 J10 H11 K8 G11 G12 I12
MESSAGE Depth 19-31 | Eval 477 | Time 2971ms | G10 G9
MESSAGE Speed 275K | Depth 19-31 | Eval 477 | Node 819K | Time 2971ms
6,9
OptiTime/MaxTime: 시간 관리가 정한 목표 시간과 상한입니다. 한 수 3,000ms 에서 여유 30ms 를 뺀 2,970ms 가 상한, 그 0.9배인 2,673ms 가 목표입니다 (Part 3 6절).Depth 19-31: 반복 심화의 명목 깊이가 19, 연장과 VCF 잎 탐색까지 포함해 실제로 내려간 가장 깊은 곳이 31수라는 뜻입니다.Eval 477: 두는 쪽(여기서는 흑인 엔진) 기준 평가값입니다.1 / (1 + exp(−477/200))로 바꾸면 승률 약 92% 입니다. 자유룰 오목은 흑 필승이니 자연스러운 값입니다.- 뒤의 좌표들: 주 변화(PV), 즉 엔진이 예상하는 최선 수순입니다.
Speed 275K | Node 819K: 초당 27만 5천 노드, 총 81만 9천 노드를 읽었습니다.
재미있는 것은 깊이가 한 단계 늘 때마다 평가와 예상 수순이 계속 바뀐다 는 점입니다. 깊이 12에서는 G10 다음 F10 을 예상하다가 13에서는 G8 로 바뀌고, 마지막에는 시간이 다 되어 중간에 끊긴 깊이 19 결과(PV 가 G10 G9 로 짧음)로 끝났습니다. 시간 제한 안에서 “지금까지 가장 믿을 만한 답” 을 내는 반복 심화의 모습 그대로입니다.
4. 같은 국면, 다른 룰#
규칙이 엔진의 판단을 어떻게 바꾸는지 보여 주는 국면을 만들었습니다. 흑이 둘 차례입니다. 네 귀퉁이의 백돌은 흑·백 돌 수를 맞추려고 멀리 둔 것입니다.
BOARD 명령으로 국면을 통째로 보냅니다. 각 줄은 x,y,색 이고, 색은 1 = 엔진 자신의 돌, 2 = 상대 돌입니다. 두는 순서대로 적습니다.
BOARD
4,7,1
3,7,2
5,7,1
7,3,2
...
DONE
4.1 자유룰: 쌍사로 즉시 승리#
MESSAGE Depth 2-2 | Eval +M3 | Time 0ms | H8
...
MESSAGE Speed 50000 | Depth 26-2 | Eval +M3 | Node 50 | Time 0ms
7,7
h8 에 두면 가로(e8~h8)와 세로(h5~h8)에 동시에 사가 생깁니다. 백은 i8 과 h9 중 한 곳만 막을 수 있으니 흑이 이깁니다. Eval +M3 은 3수(흑 h8, 백 응수, 흑 오목) 안에 이긴다 는 필승 표시입니다. 탐색한 노드는 50개, 시간은 0ms 입니다. Part 3 의 즉승 판정이 Pattern4 개수만 보고 결론을 낸 것입니다.
4.2 렌쥬: 같은 자리가 금수#
렌쥬에서 흑의 사사는 금수입니다. YXSHOWFORBID 로 금수 자리를 물어봅니다.
INFO rule 4
YXBOARD
4,7,1
...
DONE
YXSHOWFORBID
FORBID 0707.
YXBOARD 는 국면만 놓고 생각은 시키지 않는 Yixin-Board 확장 명령입니다. 응답은 좌표를 두 자리씩 붙여 적은 목록이고, 0707 은 7,7 즉 h8 이 금수 라는 뜻입니다. 자유룰(INFO rule 0)로 같은 질문을 하면 FORBID . (없음) 이 돌아옵니다.
그러면 렌쥬에서 엔진은 어떻게 둘까요.
MESSAGE Depth 2-5 | Eval +M7 | Time 2ms | G6 F7 I8 H8
...
MESSAGE Speed 352K | Depth 26-5 | Eval +M7 | Node 81K | Time 230ms
6,5
h8 을 버리고도 7수 필승(+M7) 을 찾았습니다. 예상 수순을 도해로 그리면 이렇습니다.
- 흑 g6: e8·g6·h5 로 대각선에 띈 삼을 만듭니다. g6·h7 도 대각선으로 이어집니다.
- 백 f7: 띈 삼의 가운데를 막습니다.
- 흑 i8: 가로 e8·f8·g8·□·i8 로 사 가 되고, 동시에 g6·h7·i8 대각선이 열린 삼 이 됩니다. 사삼입니다.
- 백 h8: 사는 막아야 합니다. 흑에게 금수였던 h8 이 백에게는 아무 문제 없는 자리입니다.
- 흑 f5: f5·g6·h7·i8 이 열린 사 가 됩니다. e4 와 j9 를 동시에 막을 수 없으므로 7수째에 흑이 오목을 완성합니다.
4수까지 둔 국면을 따로 물어보면 엔진은 곧바로 f5 를 두며 +M3 을 표시합니다. 사만으로 이어 가는 VCF 가 아니라 삼을 섞은 수순이고, 엔진이 이 필승을 처음 표시한 것은 탐색 시작 후 2ms 였습니다.
5. Multi-PV: 花月 주형 분석#
YXNBEST n 은 상위 n개 후보를 각각 끝까지 탐색해 보여 줍니다(Multi-PV). 앞의 花月 국면(흑 h8, 백 h9, 흑 i9)에서 백의 4수째 후보 셋을 스레드 4개, 5초로 물었습니다.
START 15
INFO timeout_turn 5000
INFO rule 4
INFO thread_num 4
YXBOARD
7,7,2
7,8,1
8,8,2
DONE
YXNBEST 3
...
MESSAGE (1) -320 | 16-18 | G7 I10 I8 J7 J8 K10 L10 K9 K8 M8 L9 L7 K7 G10 H10 H11 I12
MESSAGE (2) -502 | 16-24 | I8 J7 I7 I6 K8 H6 G6 G7 F6 F8 I5 E7 E8 H10 G9 G11 J8
MESSAGE (3) -546 | 16-29 | I10 J10 K11 G7 F6 G8 I8 H7 I7 F9 E10 G10 G9 H5 H6 I6 J5 G4 J7
...
MESSAGE Speed 1597K | Depth 16-18 | Eval -320 | Node 7937K | Time 4970ms
6,6
| 순위 | 수 | 평가 | 백 승률 환산 |
|---|---|---|---|
| 1 | g7 (A) | −320 | 약 17% |
| 2 | i8 (B) | −502 | 약 8% |
| 3 | i10 (C) | −546 | 약 6% |
세 후보 모두 백이 크게 불리합니다. 옛 RIF 룰에서 花月이 흑 필승으로 분류된 것과 맞는 결과입니다. 1순위 g7 은 흑 두 점이 이룬 대각선(h8·i9)의 연장선을 막는 수이고, 2·3순위는 흑 돌에 바로 붙이는 수입니다.
스레드 4개일 때 속도는 초당 약 160만 노드였습니다. 같은 국면을 1스레드, 1초로 물었을 때(6절)는 초당 약 26만 노드였으니 약 6배입니다. 스레드 수(4)보다 많이 늘어난 이유는 이 측정만으로는 알 수 없습니다. 탐색 시간도 1초와 5초로 달랐습니다.
6. 기력 낮추기: INFO STRENGTH#
INFO STRENGTH n (0~100, 기본 100)은 Stockfish 에서 가져온 기력 조절 장치입니다 (Part 3 참고). 花月 국면의 백 4수째를 한 수 1초로, 수준마다 새 프로세스로 6번씩 물었습니다.
| STRENGTH | 깊이 상한 | 탐색 노드 | 시간 | 6번 둔 수 |
|---|---|---|---|---|
| 100 | 없음 (18까지 도달) | 약 25만 | 약 970ms | g7 ×6 |
| 50 | 11 | 약 3만 4천 | 약 115ms | g7 ×5, i10 ×1 |
| 10 | 5 | 2,177 | 3ms | g7 ×3, g8 ×2, i7 ×1 |
깊이 상한은 4 + (−24)·(0.5^(n/100) − 1) 을 정수로 자른 값으로, 코드(search/skill.h)의 공식대로 50 → 11, 10 → 5 가 나왔습니다. 깊이 상한에 먼저 닿으면 시간이 남아도 멈추므로 STRENGTH 를 낮추면 응답도 빨라집니다. 그리고 상위 여러 후보 중에서 평가 차이와 난수를 섞어 하나를 고르기 때문에 같은 국면에서도 수가 달라집니다.
한 가지 주의할 점이 있습니다. STRENGTH 0 에서도 필승 수순은 놓치지 않습니다. 4.2절의 렌쥬 국면을 STRENGTH 0 으로 5번 물었더니 5번 모두 Depth 4-5 | Eval +M7 로 g6 을 뒀습니다. 깊이 상한은 주 탐색에만 걸리고, 깊이 0 아래의 VCF 잎 탐색과 즉승 판정은 그대로 돌기 때문입니다. 초보자용 AI 를 만들 때 이 점이 어떤 의미인지는 Part 5 에서 다룹니다.
7. MCTS 탐색기#
INFO SEARCH_TYPE mcts 로 탐색기를 바꾸면 출력 형식이 달라집니다. 같은 花月 국면, 한 수 3초입니다.
MESSAGE (1) -311 (W 17.37, D 6.32, S 0.38) | V 28K | SD 40 | G7 J10 I8 G10 J11 I10 H10 H11 I12 ...
MESSAGE (2) -358 (W 14.30, D 4.09, S 0.35) | V 40K | SD 40 | J10 G8 I8 J7 H10 G7 G10 I10 H7 ...
MESSAGE (3) -393 (W 12.27, D 8.29, S 0.22) | V 28 | SD 8 | G8 I10 J10 J7 I7 K8 I6 J5
MESSAGE (4) -462 (W 8.99, D 4.86, S 0.30) | V 14 | SD 7 | I7 G7 F6 J8 K7
...
MESSAGE Speed 62420 | Visit 69K | Time 1119ms
6,6
| 필드 | 뜻 |
|---|---|
W / D | 그 수를 뒀을 때 백의 승률 / 무승부율 (%) |
S | 가치 추정의 표준편차. 클수록 불확실 |
V | 방문 수 |
SD | 가장 깊이 내려간 수순의 길이 |
알파-베타와 같은 g7 을 골랐고 백 승률은 약 17% 로 보았습니다. 흥미로운 것은 (2) j10 이 방문 수는 1순위보다 많은데(40K 대 28K) 순위는 2위라는 점입니다. Rapfi 의 MCTS 는 방문 수에 신뢰 하한(LCB) 보정을 더한 값으로 후보를 정렬하고 최종 수를 고르므로, 방문 수가 가장 많은 수가 곧 1순위가 되지는 않습니다.
속도 단위가 다르다는 점도 보입니다. 알파-베타가 초당 수십만 노드 를 읽는 동안 MCTS 는 초당 약 6만 방문 입니다. 방문 한 번마다 신경망 평가가 따라붙기 때문입니다.
그런데 3초를 줬는데 1.1초 만에 멈췄습니다. 원인은 메모리입니다. MCTS 는 탐색 그래프가 메모리 예산을 다 쓰면 시간이 남아도 탐색을 끝냅니다 (search/mcts/search.cpp:1063-1066). 예제 설정의 기본 해시 크기는 32MiB 이고, INFO hash_size 524288 (단위 KiB, 즉 512MiB)을 주고 다시 돌리자 목표 시간 2,673ms 를 다 쓰고 19만 5천 번을 방문했습니다. MCTS 를 쓸 때는 메모리를 넉넉히 줘야 합니다.
8. 프로그램에서 엔진 부리기#
엔진은 결국 텍스트 프로세스 이므로 어떤 언어로든 붙일 수 있습니다. 파이썬으로 가장 단순하게 짜면 이렇습니다.
import re
import subprocess
MOVE = re.compile(r"^\d+,\d+$")
def ask(engine, *cmds):
"""Send commands; return (info lines, move) once a move line arrives."""
for c in cmds:
engine.stdin.write(c + "\n")
engine.stdin.flush()
infos = []
for line in engine.stdout:
line = line.strip()
if MOVE.match(line):
return infos, line
infos.append(line)
engine = subprocess.Popen(["./pbrain-rapfi"], stdin=subprocess.PIPE,
stdout=subprocess.PIPE, text=True, bufsize=1)
engine.stdin.write("START 15\nINFO rule 4\nINFO timeout_turn 1000\n")
# Kagetsu: black h8, white h9, black i9; the engine plays white
infos, move = ask(engine, "BOARD", "7,7,2", "7,8,1", "8,8,2", "DONE")
print(infos[-1])
print("engine:", move)
engine.stdin.write("END\n")
engine.stdin.flush()
engine.wait()
MESSAGE Speed 273K | Depth 18-27 | Eval -402 | Node 266K | Time 974ms
engine: 6,6
요점은 세 가지입니다.
- 명령을 쓰고 나면 반드시 flush 합니다. 버퍼에 남은 명령은 엔진에 도착하지 않습니다.
- 응답은
x,y형태의 줄이 올 때까지 읽습니다. 그 앞의MESSAGE줄들은 탐색 과정입니다. BOARD에서 색 1 은 “엔진 자신”, 2 는 “상대” 입니다. 흑·백이 아니라 엔진 기준 이라는 점을 자주 헷갈립니다. 위 예에서는 엔진이 백을 두므로 흑 돌(h8, i9)이 2, 백 돌(h9)이 1 입니다.
이 예제는 오류 처리, 타임아웃, 동시 대국을 전혀 다루지 않습니다. 실제 서비스에서 이것들을 어떻게 다룰지는 다음 편의 주제입니다.
9. 명령 요약#
이번 편에서 쓴 명령을 모았습니다. 전체 목록은 command/gomocup.cpp 가 정본입니다. README 가 링크하는 wiki 의 Protocol 페이지는 2026-09-24 현재 열리지 않고 저장소 첫 화면으로 돌아갑니다.
| 명령 | 출처 | 용도 |
|---|---|---|
START n / BEGIN / TURN x,y / BOARD … DONE / END | Piskvork | 대국 진행 |
INFO timeout_turn / timeout_match / rule / max_memory | Piskvork | 시간·룰·메모리 |
INFO thread_num / hash_size / max_depth / max_node | Yixin-Board 확장 | 자원·탐색 제한 |
YXBOARD … DONE | Yixin-Board 확장 | 국면만 설정 (생각하지 않음) |
YXNBEST n | Yixin-Board 확장 | 상위 n개 후보 분석 |
YXSHOWFORBID | Yixin-Board 확장 | 렌쥬 금수 자리 |
YXBALANCEONE / YXBALANCETWO | Yixin-Board 확장 | 균형 수 탐색 |
YXSTOP | Yixin-Board 확장 | 생각 중단 |
INFO strength / search_type | Rapfi 고유 | 기력 조절 / 탐색기 선택 |
References#
1차 출처#
- Rapfi 저장소와 README (master
3c94c2a, 2026-07-23): https://github.com/dhbloo/rapfi - Rapfi 프로토콜 처리 코드: https://github.com/dhbloo/rapfi/blob/3c94c2a976f24a0dd1c5517623e9ab6fffe66bd7/Rapfi/command/gomocup.cpp
- Rapfi 기력 조절 코드: https://github.com/dhbloo/rapfi/blob/3c94c2a976f24a0dd1c5517623e9ab6fffe66bd7/Rapfi/search/skill.h
- Rapfi MCTS 코드: https://github.com/dhbloo/rapfi/blob/3c94c2a976f24a0dd1c5517623e9ab6fffe66bd7/Rapfi/search/mcts/search.cpp
- rapfi-networks (가중치, 예제 설정): https://github.com/dhbloo/rapfi-networks
- Rapfi Releases: https://github.com/dhbloo/rapfi/releases
배경 자료#
- Piskvork 프로토콜 명세 (최종 갱신 2023-03-20): https://plastovicka.github.io/protocl2en.htm
- Yixin-Board 프로토콜 확장 (2014): https://github.com/accreator/Yixin-protocol