aboutsummaryrefslogtreecommitdiffstats
path: root/docs/research/084-parallel-syncs.md
blob: 3f55e850bdc6a556567be3e3ac2275bc839e3211 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# 084 — 소유자를 키로 나누면, 저장을 다른 스레드로 넘기면: 이 장치는 동기화를 한 줄로 세운다

2026-09-27. [로드맵](../roadmap.md) 중기 2 "키 해시 기반 단일 소유자 파티셔닝(쓰기 병목 선형 확장)"과 단기 2 "저장과
적용의 겹치기(디스크 쓰기를 전용 I/O 스레드로)"를 할지 정한 것. 둘 다 같은 사실에 걸린다.

착수 기준:

- 바꿀 결정: 상태를 가진 앱의 소유자를 키 해시로 여럿 두고(소유자마다 저장소 하나) 내구성 있는 쓰기를 코어 수만큼
  늘릴지, 지금처럼 소유자 하나가 한 번의 동기화에 모든 이벤트를 모으게 둘지.
- 남은 질문: 이 장치가 파일 여러 개의 `fdatasync`를 동시에 처리하는가? 소유자를 나누면 앱의 초당 쓰기가 느는가?
- 최소 확인: 저장소 커밋과 같은 모양(0을 써 둔 곳을 덮어쓰고 `fdatasync`)을 파일마다 스레드 하나로 1·2·4개 동시에 돌려
  합계를 재고, 점수판 앱을 저장소 하나의 소유자 하나와 저장소를 따로 가진 소유자 둘로 번갈아 잰다.
- 중단 기준: 동기화 합계가 스레드 수를 따라 늘면(장치가 병렬로 처리하면) 샤딩을 설계한다.

## 장치가 말한 것

Raspberry Pi 5, SD 카드의 ext4, 4 KiB 커밋, 3초씩, 순서를 돌려 가며 네 라운드
([probe.c](../../research/experiments/013-parallel-syncs/probe.c), [run.py](../../research/experiments/013-parallel-syncs/run.py),
[원시 결과](../../research/experiments/013-parallel-syncs/2026-09-27-ext4-linux-aarch64.json)):

| 동시에 동기화하는 파일 | 합계 커밋/s | p50 |
|---|---:|---:|
| 1 | 512–669 | 1.21–1.23 ms |
| 2 | 556–664 | 2.42–2.45 ms |
| 4 | 626–684 | 4.93–5.09 ms |

합계는 그대로이고 지연만 파일 수에 비례한다. 장치가 동기화를 하나씩 처리한다(SD는 명령을 한 줄로 받는다).

점수판 앱의 내구성 POST(연결 64개를 나눠 받음, 서버는 CPU 0·1, 부하는 CPU 3·2):

| | posts/s | p99 |
|---|---:|---:|
| 소유자 하나, 저장소 하나 | 18,542–23,384 (중앙값 20,974) | 13.8–25.6 ms |
| 소유자 둘, 저장소 둘 | 13,991–17,716 (중앙값 16,230) | 36.9–57.3 ms |

나누면 23% 느리고 꼬리 지연이 2–3배다. 동기화는 줄을 서는데, 한 번의 동기화가 싣는 이벤트는 소유자마다 반으로 준다.
소유자 하나의 그룹 커밋은 기다리는 이벤트를 모두 한 번의 동기화에 싣는다. 이 장치에서는 그것이 쓰기의 최선이다.

## 결정

- 키 해시 샤딩은 하지 않는다. 쓰기의 병목은 CPU가 아니라(소유자의 라운드에서 적용은 4%,
  [081](081-tail-records.md)) 장치의 동기화 한 줄이다. 코어를 늘려 줄 것이 없다.
- 병렬로 동기화하는 장치(여러 큐의 NVMe)에서도 저장소를 소유자마다 두기보다 한 저장소의 그룹 커밋을 여러 소유자가 함께
  쓰는 쪽이 먼저다. 문서 서버는 이미 그렇게 스레드들의 커밋을 묶는다(`d4b01a7`). 앱의 적용이 CPU 하나를 넘을 때 소유자를
  나누되 동기화는 장치마다 한 줄로 모은다.
- 멀티코어는 읽기에 쓴다. 연결 스레드가 스냅숏으로 보기를 답한다(`--threads auto`, [080](080-snapshot-reads.md)).
- 이 장치에서 쓰기를 더 늘리는 길은 동기화의 비용 자체다. 커밋 경로에서 꼬리 늘리기와 압축을 빼면 처리량은 5–8% 늘고
  p99는 반이 된다([081](081-tail-records.md) 다음 결정).

## 저장을 다른 스레드로(단기 2)

쓰기가 포화일 때 병목은 기기의 동기화 한 줄이다. 커밋 말고 기기가 하는 일, 곧 꼬리에 0을 써 두고 새 크기를 저널에
커밋하는 일과 압축(살아 있는 노드를 새 파일에 쓰고 동기화, 이름 바꾸기, 디렉터리 동기화)을 다른 스레드로 넘겨도 그 일의
기기 시간은 사라지지 않는다. 커밋의 동기화와 같은 줄에 서서 그 커밋을 늦출 뿐이다. 소유자가 기다리는 틈(라운드의 25%,
약 1.1 ms, [081](081-tail-records.md))도 저널 커밋 한 번보다 짧다. 적용을 동기화와 겹쳐 가릴 수 있는 몫은 4%다(081).

그래서 스레드를 늘리는 대신, 그 일을 없앴을 때의 몫을 쟀다. 저장소를 셋으로 빌드해 번갈아 잰 것이다. 그대로, 압축을 뺀 것,
압축을 빼고 꼬리를 만들 때 64 MiB를 써 두어 측정 중에는 늘리지 않는 것이다([variants.py](../../research/experiments/013-parallel-syncs/variants.py),
[원시 결과](../../research/experiments/013-parallel-syncs/2026-09-27-store-variants-ext4-linux-aarch64.jsonl)). 점수판 내구성 POST
64연결, 4초씩 여섯 라운드의 중앙값이다.

| 저장소 | posts/s | p99 |
|---|---:|---:|
| 그대로 | 22,230 | 17.4 ms |
| 압축 없음 | 21,528 | 18.4 ms |
| 압축 없음, 꼬리 늘리기 없음 | 22,544 | 13.3 ms |

압축만 빼서는 달라지지 않는다. 꼬리 늘리기까지 빼면 처리량은 +1.4%(앞선 탐색에서는 +5.5%)이고 p99가 24% 준다. 꼬리
지연을 만드는 것은 새 블록이다. 256 KiB마다 0을 쓰고, 그 크기와 블록을 저널에 커밋하는 동기화다. 0을 크게 한꺼번에
쓰면 드물어지는 대신 한 번이 길어진다. 압축은 새 파일을 만들 때마다 꼬리를 새로 써야 해서 둘은 따로 떼어 볼 수 없다.

결정: 저장을 넘길 스레드는 만들지 않는다. 남은 몫(p99 −24%, 처리량 몇 %)을 얻는 길은 공간을 제자리에서 다시 쓰는
저장소다. 파일을 한 번 0으로 채워 두고 커밋을 고리처럼 돌려 쓰며, 살아 있는 노드를 앞으로 옮기는 청소가 압축을 대신한다.
그러면 커밋은 늘 이미 있는 블록을 덮어쓰고(가장 싼 동기화), 새 파일과 이름 바꾸기도 없다. 저장소 형식을 바꾸는 일이라
따로 설계한다.