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
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
|
# 107 — 스냅숏이 놓은 노드를 목록이 다시 쓴다
2026-09-30. [106](106-tpcc-conditions-first.md)의 판정 2에서 이어진다. RAM에서 TPC-C는 소유자 스레드 하나가 병목이고, 표본으로 본
소유자의 바쁜 시간 가운데 3분의 1이 스냅숏이 공유한 노드를 복사하고 놓는 데 갔다. 그 3분의 1이 무엇인지 나눠 보고 줄였다.
기준(106의 표본에서 곧바로 이어진 일이라, 측정 전에 따로 커밋하지는 않았다):
- **바꿀 결정.** 스냅숏에 딸린 비용을 코어 안에서 줄일지, 할당기를 바꿀지, 둘 다 하지 않을지.
- **넣는 조건.** 번갈아 잰 쌍에서 처리량과 거래당 CPU가 나아진다. 상태 바이트에 대한 상주 메모리가 늘지 않는다. 붙든 스냅숏이
읽는 값이 그대로다(테스트, ASan과 LSan).
## 쉬운 말로
- **스냅숏 때문에 소유자가 쓰던 시간의 70%는 복사가 아니라 할당이었다.** 그 몫의 표본 20개 가운데 할당기가 14개, 복사가 4개였다.
- TPC-C의 잎(약 1.3 KB)은 glibc의 스레드 캐시(1,032 B까지)에 들지 않는다. 그래서 malloc과 free가 매번 아레나의 잠금을 잡는다.
- 스냅숏을 마지막으로 놓는 쪽이 연결 스레드면, 그 스레드가 소유자의 아레나에 free한다. 소유자의 malloc과 같은 잠금을 다툰다.
- **할당기를 jemalloc으로 바꾸면 11–14% 빨랐다.** 할당이 정말 비용이었다는 확인이다. 대신 상주가 상태 바이트의 2.17배에서
2.24배로 늘었다.
- **할당기를 바꾸지 않고, 스냅숏이 놓은 노드를 목록이 다시 쓰게 했다.**
- 목록에는 원래 제가 놓은 노드를 다음에 쓰는 자리(spare)가 있었다. 스냅숏이 놓은 노드는 거기 오지 않고 free됐다. 이제 온다.
- 번갈아 잰 여섯 쌍에서 처리량의 중앙값이 +9.4%다(다섯 쌍 +9.3–13.0%, 한 쌍 −2.5%).
- 거래당 CPU는 여섯 쌍 모두 4–8% 줄었다.
- 상주는 상태 바이트의 2.17–2.18배에서 1.97–1.98배로 줄었다. 빨라지면서 메모리도 덜 쓴다.
- **TPC-C의 RAM 비교가 바뀌었다.** W=1에서 초당 40,976건으로 조율한 SQLite(10,164건)의 4.0배다. W=4에서는 35,838건, 3.5배다.
106에서는 36,567건과 32,765건이었다.
- 소유자 표본에서 스냅숏에 딸린 몫은 바쁜 시간의 33%에서 17%로 줄었다. 할당기 표본은 14개에서 7개가 됐다.
## 무엇이 비쌌나
106의 소유자 표본([원시](../../research/experiments/022-tpcc-grouped/2026-09-30-owner-stacks-linux-aarch64.txt)) 가운데
스냅숏에 딸린 20개를, 가장 안쪽에서 부른 할당기와 그 호출자로 나눴다.
| 가장 안쪽 | 표본 |
|---|---:|
| `malloc` ← `new_node`(목록이 복사할 노드를 새로 얻는다) | 9 |
| `free` ← `pure_retired_free`(스냅숏이 놓인 뒤 그 노드를 돌려준다) | 4 |
| `realloc` ← `retire`(앱의 retired 벡터가 스냅숏마다 16칸부터 다시 자란다) | 1 |
| 복사 자체(`own` 안의 memcpy)와 그 밖 | 6 |
같은 서버를 할당기만 바꿔 번갈아 돌렸다(RAM, W=1, 64터미널, 20초씩).
| 할당기 | 초당 거래 | 거래당 CPU | 상주 / 상태 |
|---|---:|---:|---:|
| glibc | 37,280 | 48.2 µs | 2.17배 |
| jemalloc | 42,323 | 43.4 µs | 2.24배 |
| glibc, `tcache_max=4096` | 39,799 | 47.0 µs | 2.18배 |
| glibc | 40,515 | 46.8 µs | 2.18배 |
| jemalloc | 45,119 | 43.5 µs | 2.24배 |
jemalloc은 glibc 바로 앞 실행보다 13.5%와 11.4% 빨랐다. glibc의 스레드 캐시를 키우는 설정은 아무것도 바꾸지 않았다(이 판의
glibc는 1,032 B를 넘는 크기를 캐시하지 않는다). 같은 glibc 두 번이 8.7% 벌어진다. 이 기계에서 한 번의 실행은 ±5%쯤 흔들린다.
## 한 것
[`core/module-list.c`](../../core/module-list.c), [`core/module.c`](../../core/module.c) (`6c87722`):
1. **retired 항목이 제 노드를 설명한다.** 노드의 공유 세대(`made`), 크기, 잎인지를 항목에 적는다. 스냅숏을 놓을 때 넘기는 일과
되쓰는 일이 식은 노드를 읽지 않는다(전에는 넘길 때마다 노드의 `made`를 읽었다).
2. **놓인 것은 앱으로 돌아온다.** 스냅숏을 마지막으로 놓는 스레드는 아무 스냅숏도 읽지 않는 것을 free하지 않는다. 대신 앱의
레지스터(앱과 그 스냅숏들이 함께 쥔다)에 둔다. 레지스터의 스핀 잠금 아래이고, 전에도 넘기는 일은 이 잠금 아래였다.
3. **소유자가 나눠 준다.** 스냅숏을 만들 때마다 소유자는 돌아온 것을 크기가 맞는 목록의 spare로 나눠 준다. 목록은 최근에 만든
노드 수만큼 쥔다(돌아올 때마다 반으로 줄여 센다, 적어도 8개). 남는 것과 text는 free한다.
4. **spare는 포인터 배열이다.** 노드 안에 끼운 연결이면 나눠 줄 때마다 식은 노드에 써야 한다.
5. 앱의 retired 벡터는 칸을 두고 되쓴다.
처음 판(커밋하지 않았다)은 spare를 노드 안의 연결로 두고, 돌아올 때마다 수요를 0으로 지웠다. 세 쌍에서 +7.4%에 그쳤다. 표본을
보니 나눠 주는 고리가 식은 노드를 읽고 쓰느라 바빴고(표본 4개), 수요를 지우는 바람에 돌아온 것의 상당수를 free했다(표본 5개).
위의 1, 3, 4가 그것을 고친 것이다.
공유되지 않은 노드를 놓을 때(`drop_node`)는 전처럼 8개까지만 spare로 쥔다. 스냅숏을 만들지 않는 앱에서는 수요가 반으로 줄
기회가 없기 때문이다.
## 안전
되쓰는 노드는 전에 free하던 바로 그것이다. 아무 스냅숏도 읽지 않는다는 판정(`pure_retired_pass`)은 바꾸지 않았다.
- `snapshot_held`(이미 있던 테스트): 스냅숏 하나를 붙든 채 사건과 스냅숏을 4만 번 돌린 뒤, 붙든 스냅숏의 내용이 글자 하나 다르지
않다.
- `list_trees`: 무작위 변경 6,000번 사이에 복사본을 넷까지 붙들고, 가장 오래된 것을 놓으면 그 retired를 이제 목록이 되쓴다.
붙든 복사본의 항목을 계속 확인한다. 아직 붙든 복사본의 retired를 되쓰게 바꾼 변이는 여기서 잡혔다.
- `retired_come_back`(새 테스트): 복사본이 놓은 잎으로 다음 복사를 만든다(주소가 같다). 붙든 복사본은 제 값을 읽는다. 되쓰지
않게 바꾼 변이는 여기서 잡혔다.
- 모듈 테스트는 ASan과 LSan으로도 돈다. Pure의 `make check`, Vega와 aup의 검사가 통과했다.
## 결과
[원시 결과](../../research/experiments/023-node-reuse/)와 [`runs.sh`](../../research/experiments/023-node-reuse/runs.sh),
표는 `summary.py`가 만든다. RAM(tmpfs), W=1, 64터미널, 20초씩, 전(`c9fda91`)과 후(`6c87722`)를 번갈아.
| 쌍 | 초당 거래(전 → 후) | 거래당 CPU(µs) | 상주 / 상태 |
|---|---:|---:|---:|
| 1 | 39,304 → 38,305 (−2.5%) | 47.4 → 45.6 | 2.18 → 1.97 |
| 2 | 38,863 → 43,811 (+12.7%) | 47.0 → 43.8 | 2.18 → 1.97 |
| 3 | 39,952 → 43,732 (+9.5%) | 47.0 → 43.9 | 2.18 → 1.97 |
| 4(후 먼저) | 37,931 → 41,485 (+9.4%) | 48.4 → 44.7 | 2.17 → 1.98 |
| 5(후 먼저) | 36,817 → 41,617 (+13.0%) | 48.2 → 45.0 | 2.17 → 1.97 |
| 6(후 먼저) | 38,679 → 42,292 (+9.3%) | 47.9 → 44.7 | 2.18 → 1.97 |
첫 쌍의 "후" 실행에서는 소유자가 코어의 0.83만 썼다(다른 실행은 0.87–0.91). 그 실행에서도 거래당 CPU는 3.8% 줄었다.
**106의 RAM 행렬을 다시.** SQLite는 그대로다.
| | W=1 | W=4 |
|---|---:|---:|
| Pure, 16터미널 | 38,320 (106: 35,672) | 33,057 (32,765) |
| Pure, 64터미널 | 40,976 (36,567) | 35,838 (32,255) |
| 조율한 SQLite의 최고 | 10,164 | 10,168 |
| Pure의 상주 / 상태 | 2.01 (2.23) | 1.94 (2.10) |
같은 거래 2만 건 뒤 세 쪽의 표가 모두 같았고, 모든 실행 뒤 일관성 조건이 성립했다.
**소유자 표본**(RAM, W=1, 64터미널, 80개씩).
| 소유자가 하던 일 | 106(전) | 지금 |
|---|---:|---:|
| 항목 찾기 | 24 | 20 |
| 요청 기다리기 | 19 | 17 |
| 스냅숏이 공유한 노드를 복사하기 | 15 | 10 |
| 스냅숏 게시와 놓기 | 5 | 1 |
| 해석기 | 5 | 10 |
| 항목 바꾸기 | 3 | 10 |
| 항목 읽기 | 3 | 0 |
| 답 넘기기 | 3 | 7 |
| 저장과 그 밖 | 3 | 5 |
| (할당기가 가장 안쪽인 표본) | (14) | (7) |
스냅숏에 딸린 몫(복사와 게시)이 바쁜 시간의 33%에서 17%가 됐다. 남은 할당기 표본 7개 가운데 4개는 `new_node`의 malloc이다.
목록이 자라 새 잎이 필요하거나, 연결 스레드가 스냅숏을 오래 붙들어 돌아온 것이 늦을 때다. 칸마다의 차이는 ±3–4개가 흔들림이다.
## 판정
- 넣었다. 처리량 중앙값 +9.4%, 거래당 CPU −4–8%, 상주/상태 −10%. jemalloc의 이득(11–14%)에 가깝고, 메모리는 jemalloc과
반대로 줄었다. Pure는 할당기를 바꾸지 않는다.
- 106의 기준으로 소유자는 여전히 병목이다. W=4, 64터미널에서 코어의 0.92를 쓰고, 처리량은 16터미널보다 8.4% 많다(기준 10%).
## 다음
- 소유자의 바쁜 시간은 이제 찾기 32%, 바꾸기·복사·해석기 각 16%, 답 넘기기 11%, 저장 8%다. 가장 큰 몫은 B+트리를 내려가는
찾기다. 한 사건 안에서 같은 목록을 여러 번 찾는다(New-Order는 재고를 열 번). 찾은 자리를 기억하는 손가락은 읽기에만 있다.
- 남은 복사는 저장마다 스냅숏을 게시하는 한 든다. 줄이려면 잎을 통째로 복사하지 않는 방법이 필요하다.
- SD 카드에서는 소유자가 코어의 7–15%만 쓰므로(106) 이 변경이 처리량을 바꿀 까닭이 없다. 재지 않았다.
|