이 글은 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 1515x15 판으로 시작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. 같은 국면, 다른 룰#

규칙이 엔진의 판단을 어떻게 바꾸는지 보여 주는 국면을 만들었습니다. 흑이 둘 차례입니다. 네 귀퉁이의 백돌은 흑·백 돌 수를 맞추려고 멀리 둔 것입니다.

abcdefghijklmno151413121110987654321
흑 차례. 흑이 * 에 두면 가로와 세로로 동시에 사가 됩니다

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) 을 찾았습니다. 예상 수순을 도해로 그리면 이렇습니다.

abcdefghijklmno15141312111098765432143215
렌쥬에서 엔진이 찾은 수순. 1·3·5 흑, 2·4 백
  1. 흑 g6: e8·g6·h5 로 대각선에 띈 삼을 만듭니다. g6·h7 도 대각선으로 이어집니다.
  2. 백 f7: 띈 삼의 가운데를 막습니다.
  3. 흑 i8: 가로 e8·f8·g8·□·i8 로 사 가 되고, 동시에 g6·h7·i8 대각선이 열린 삼 이 됩니다. 사삼입니다.
  4. 백 h8: 사는 막아야 합니다. 흑에게 금수였던 h8 이 백에게는 아무 문제 없는 자리입니다.
  5. 흑 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
efghijk111098765CBA
花月 국면에서 백 4수째 후보. A 1순위, B 2순위, C 3순위
순위수평가백 승률 환산
1g7 (A)−320약 17%
2i8 (B)−502약 8%
3i10 (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만약 970msg7 ×6
5011약 3만 4천약 115msg7 ×5, i10 ×1
1052,1773msg7 ×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 / ENDPiskvork대국 진행
INFO timeout_turn / timeout_match / rule / max_memoryPiskvork시간·룰·메모리
INFO thread_num / hash_size / max_depth / max_nodeYixin-Board 확장자원·탐색 제한
YXBOARD … DONEYixin-Board 확장국면만 설정 (생각하지 않음)
YXNBEST nYixin-Board 확장상위 n개 후보 분석
YXSHOWFORBIDYixin-Board 확장렌쥬 금수 자리
YXBALANCEONE / YXBALANCETWOYixin-Board 확장균형 수 탐색
YXSTOPYixin-Board 확장생각 중단
INFO strength / search_typeRapfi 고유기력 조절 / 탐색기 선택

References#

1차 출처#

배경 자료#