aboutsummaryrefslogtreecommitdiffstats
path: root/docs/research/008-json-fanout-results.md
blob: 5fcc5d0657d38f31fa9e7be62aed75fd7fbbc5ef (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
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
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
# Pure 연구 보고서 008: 실제 JSON 파서와 여러 소비자의 버퍼 수명

작성일: 2026-09-22
상태: **실제 파서를 연결한 비교 구현·검증·210회 측정 완료. Pure 고유의 우위는 확인하지 못함.**

## 이번 판단

실제 JSON과 여러 소비자를 붙여도 원본 유지 비용은 재현됐다.
그리고 이번 범위에서도 **작은 결과를 일찍 복사하고 같은 결과의 복사본을 공유하는 기존 방법이 효과적이었다.**
소비자가 하나만 남았을 때 뒤늦게 복사하는 후보는 원본의 유지 시간을 줄였지만,
최대 메모리를 충분히 줄이지 못하거나 단순 크기 기준보다 진행이 느렸다.

이제 ‘공유 버퍼를 어떻게 복사할 것인가’만으로 새로운 언어·실행기·ABI의 필요성을 설명하지 않는다.
수명·예산 관리는 Pure의 기본 품질 요건으로 남기고, 연구의 대표 문제로서 우선순위를 낮춘다.
다음 우선 후보는 006에서 정한 **작은 변경이 큰 재계산을 일으키는 편집·상태 갱신 문제**다.

사용 경험에 연결되는 관찰은 분명하다. 작은 메타데이터를 느린 소비자에게 넘겼다는 이유로
이미 처리한 큰 본문까지 붙잡을 필요는 없다. 다만 이것을 사용자 앱이나 Pure 문법으로 구현한 것은 아니다.
이번 산출물은 [실행 가능한 비교 CLI](../../research/experiments/003-json-fanout/README.md)이며,
사용자가 새 데모를 조작해 평가할 과제는 없다.

## 이전 실험에서 보완한 것

[007](007-buffer-retention-results.md)은 미리 알려진 바이트 범위와 단일 소비자만 다뤘다.
이번에는 다음을 추가했다.

- **실제 JSON 파싱:** 버전을 고정한 yyjson 0.12.0으로 줄마다 JSON 전체를 파싱한다.
  선택하지 않는 필드도 구문 검사를 거친다. Python의 별도 JSON 디코더와 실제 출력 바이트를 비교한다.
- **최대 세 소비자:** 서로 다른 필드 또는 같은 필드를 소비하고, 각각 다른 시점에 완료한다.
  소비자별 순서는 유지하지만 소비자 사이의 출력 순서는 달라질 수 있다.
- **공유 소유권:** 같은 원본을 여러 번 세지 않는다. 마지막 참조가 반납되어야 해당 할당을 반환한다.
- **파서 내부 메모리:** yyjson의 할당·재할당·해제 콜백을 같은 예산에 연결했다.
  입력·결과만 작게 보이게 하고 파서 작업 공간을 제외하는 비교를 피했다.

yyjson은 입력을 직접 사용해 문자열을 디코딩하는 in-situ 옵션을 제공한다.
필요한 문자열의 위치와 길이를 얻은 뒤 파서 문서 메타데이터는 즉시 해제하고,
문자열이 있는 입력 버퍼의 소유권만 유지했다.
실험에 사용한 [고정 버전 소스](https://github.com/ibireme/yyjson/tree/8b4a38dc994a110abaec8a400615567bd996105f)와
MIT 라이선스·해시는 저장소에 함께 보관했다.

## 비교 조건

모든 방식이 같은 파서, 읽기 경로, 소비자 스케줄러, 예산 검사와 출력 형식을 사용한다.
각 소비자의 큐는 최대 64개다. 기본 지연은 첫 소비자 0 ms, 둘째 5 ms, 셋째 8 ms다.
이는 실제 sleep으로 넣은 **합성 보관 시간**이며 네트워크나 서비스의 관측 지연이 아니다.

| 방식 | 결정 |
|---|---|
| `borrow` | 모든 소비자가 원본을 참조 |
| `copy` | 소비자마다 필요한 문자열을 별도로 복사 |
| `shared_copy` | 같은 원본 범위를 선택한 소비자끼리 복사본 하나를 공유 |
| `threshold2/8` | 원본/결과의 할당 용량 비율이 2·8 이상이면 복사하고 같은 복사본을 공유 |
| `compact` | 소비자 완료 후 원본 참조가 하나만 남으면 작은 결과를 복사 시도 |
| `copy_no_pool` | 독립 복사와 같은 용량 구분을 쓰되 빈 버퍼 캐시는 끔 |

`compact`는 현재 참조 상태만 보는 단순 후보다. 미래의 보관 시간을 미리 알지 않는다.
다른 소비자가 여전히 원본을 참조할 때 그 버퍼를 옮기지 않는다.
단일 소비자 조건에서는 완료 전 참조 수 변화가 없으므로 이 후보의 복사 시도도 없다.
이 특정 후보가 모든 동적 정책을 대표하지는 않는다.

6개 조건 × 7개 방식 × 5회로 **210개 측정**을 수행했다.
기본 입력은 본문 32 KiB·패딩 8 KiB와 작은 문자열·메타데이터를 포함한 JSON 1,024개, 약 40.13 MiB다.
혼합 입력은 약 47.57 MiB, 메타데이터가 많은 입력은 512개·약 5.02 MiB다.
모두 생성 규칙과 seed를 공개한 합성 데이터다. 실제 운영 트래픽은 아니다.

호스트는 Linux AArch64, Cortex-A76, GCC 14.2.0이다. 한 CPU로 고정했으나 온도·전력은 통제하지 않았다.
워밍업 뒤 방식 순서를 반복마다 섞었다. 아래 값은 5회 중앙값이며 통계적 유의성 검정은 하지 않았다.
사용자 Mac에서의 측정도 아니다.

## 결과 1: 느린 작은 소비자가 큰 원본을 붙잡는다

첫 소비자가 본문을 바로 출력하고, 두 느린 소비자가 작은 `tag` 문자열을 보관하는 조건이다.
‘관리 공간’은 고정 상태·큐·입력·결과·파서·빈 버퍼 캐시를 포함한 최대 요청 바이트다. 전체 RSS와 다르다.

| 방식 | 최대 관리 공간 MiB | 명시적 선택 복사 MiB | 전체 완료 시간 ms |
|---|---:|---:|---:|
| 원본 공유 | 4.230 | 0 | 132.60 |
| 소비자마다 복사 | 0.304 | 32.109 | 132.28 |
| 복사본 공유 | 0.297 | 32.055 | 132.28 |
| 크기 비율 2 기준 | 0.297 | 32.055 | 132.24 |
| 크기 비율 8 기준 | 0.297 | 0.055 | 132.18 |
| 마지막 참조에서 복사 | 4.231 | 0.055 | 132.70 |

크기 비율 8 정책은 빠른 본문 소비자에게 원본을 잠깐 빌려주고 작은 문자열만 독립시켰다.
이 조건에서는 약 **4.23 → 0.297 MiB**로 관리 공간이 줄면서 명시적 복사도 작았다.
이것은 일반 C 코드의 정책 변경으로 얻은 결과다. Pure를 호출하거나 컴파일하지 않았다.

원본 공유와 마지막 참조에서의 복사는 최대 공간이 거의 같았다.
둘째 소비자가 완료될 때까지는 원본을 반납할 수 없기 때문이다.
원본 활성 바이트·시간의 중앙값은 약 540,593에서 340,108 byte·seconds로 줄었지만,
이미 형성된 최대 유지량은 해결하지 못했다.

모든 방식에서 최대 큐 개수는 `[1, 64, 64]`였다. 느린 두 소비자의 내부 P99 상한도 각각
약 5.24 ms, 8.39 ms였다. 이 수치는 입력이 프로그램 밖에서 대기한 시간을 포함하지 않는다.
완료 시간은 합성 지연의 영향을 크게 받으므로 1 ms 미만의 차이를 서비스 속도 개선으로 해석하지 않는다.

## 결과 2: 소비자마다 복사하면 같은 결과도 중복된다

세 소비자가 모두 같은 32 KiB 본문을 선택하도록 바꿨다.

| 방식 | 최대 관리 공간 MiB | 명시적 선택 복사 MiB | 완료 시간 ms |
|---|---:|---:|---:|
| 원본 공유 | 4.230 | 0 | 133.81 |
| 소비자마다 복사 | 4.296 | 96 | 134.44 |
| 복사본 공유 | 2.262 | 32 | 133.59 |
| 크기 비율 2 기준 | 2.262 | 32 | 133.66 |
| 마지막 참조에서 복사 | 4.512 | 32 | 134.20 |

소비자별 독립 복사는 원본을 해제하면서도 결과를 중복 보관했다.
**한 번 만든 복사본을 공유하는 기존 기법**으로 복사량과 관리 공간을 줄일 수 있었다.
같은 내용의 모든 문자열을 검색·통합한 것은 아니다. 한 레코드에서 같은 위치와 길이를 선택한 경우만 공유했다.

늦은 복사는 복사 중 원본과 결과의 공존, 빈 버퍼 캐시 때문에 최대 공간이 더 커질 수도 있었다.
‘마지막 순간에 복사하면 항상 메모리가 작다’는 결론은 성립하지 않았다.

## 결과 3: 좁은 예산에서는 단순한 정책이 작업 정체도 줄인다

본문·패딩 크기가 섞인 JSON을 2 MiB 예산으로 처리했다.
빠른 소비자는 본문을, 느린 소비자들은 작은 문자열을 선택한다.

| 방식 | 최대 관리 공간 MiB | 완료 시간 중앙값 ms | 측정 범위 ms |
|---|---:|---:|---:|
| 원본 공유 | 2.000 | 516.88 | 516.42~519.74 |
| 복사본 공유 | 1.047 | 134.70 | 134.66~134.73 |
| 크기 비율 2 기준 | 1.047 | 134.65 | 134.60~134.81 |
| 크기 비율 8 기준 | 1.047 | 134.63 | 134.51~135.14 |
| 마지막 참조에서 복사 | 2.000 | 330.51 | 330.33~332.81 |

큰 원본이 남아 있으면 다음 입력과 파서가 사용할 공간이 부족해진다.
늦은 복사도 정체를 완화했지만, 이 작업에서는 작은 결과를 일찍 분리하는 방법이 더 효과적이었다.
이 수치는 특정 파이프라인의 처리 시간이며 서버나 Pure 전체가 몇 배 빨라진다는 뜻은 아니다.

큰 본문을 **느린** 소비자에게 보내는 별도 조건에서는 크기 비율 8 정책의 최대 관리 공간이
4.237 MiB, 비율 2 정책은 2.268 MiB였다. 복사량을 적게 만드는 기준이 메모리까지 항상 최소화하지 않는다.
소비자의 역할과 실제 할당 크기를 같이 봐야 한다.

## 파서와 풀을 빼면 결과가 왜곡된다

메타데이터가 많은 입력에서는 크기 기준 정책의 최대 관리 공간이 약 0.172 MiB였다.
그 실행의 파서 활성 할당 최고치는 약 0.094 MiB였다.
혼합 입력에서는 파서 최고치가 약 0.500 MiB였다. 입력·출력만 계측해서는 이 공간을 설명할 수 없다.
각 영역의 최고치는 발생 시점이 다를 수 있으므로 서로 더해서 총 최고치라고 하지 않는다.

파서 콜백이 메모리 부족 시 기존 소비자를 진행시키도록 했으므로,
`parse_call_ns`에는 그 진행과 대기 시간이 섞일 수 있다. 순수 파서 CPU 비용으로 사용하지 않는다.
전체 처리 시간·프로세스 CPU·영역별 메모리를 별도로 기록했다.

또한 빈 버퍼를 보관하는 풀은 할당을 재사용하는 대신 공간을 유지한다.
세 소비자의 다른 속도 조건에서 독립 복사는 풀을 켜면 약 0.304 MiB,
끄면 약 0.241 MiB였다. 풀의 존재만으로 메모리 최적화를 주장하지 않는다.
이번 작은 풀을 각 플랫폼의 최선의 할당기와 비교한 것도 아니다.

## 검증과 재현 가능한 범위

일반 빌드와 ASan·UBSan 빌드에서 총 **108개 검사 그룹**이 통과했다.
파서 자체도 계측해서 빌드했다. 검사는 다음을 포함한다.

- 1·2·3 소비자의 독립 출력, 큐 슬롯 재사용, Unicode·이스케이프·NUL·빈 문자열.
- 파서 문서 해제 뒤에도 남아 있는 입력/복사본의 유효성, 공유 결과의 마지막 반납.
- 잘린 JSON, 잘못된 필드 타입·누락, 잘못된 UTF-8, 선택하지 않은 필드의 구문 오류.
- 파서 작업 공간이 예산을 넘는 경우의 실패와 반납, 너무 긴 레코드 거절.
- 취소·SIGTERM·실제 출력 실패와 남은 소유권 정리.

210개 측정 모두 Python JSON 디코더로 만든 소비자별 기대 해시·개수와 일치했고,
모든 측정에서 관리 예산을 지켰으며 종료 후 남은 버퍼 할당은 0이었다.
이 검사가 yyjson의 모든 동작이나 다중 스레드·장치 취소 경합을 검증한 것은 아니다.

## 남은 한계와 다음 결정

이 실험은 실제 파서를 쓰지만 입력·소비자 지연은 합성이고 실행은 한 스레드다.
JSON 한 줄을 모두 읽은 뒤 처리하며 지속 캐시·네트워크·독립적인 요청 도착·GPU를 다루지 않는다.
Sonic·Node.js·.NET 자체의 비교도 아니다. 운영 부하에서 자동 정책이 유리할 가능성까지 기각하지 않는다.

현재 증거로 결정할 수 있는 범위는 다음과 같다.

1. 버퍼 수명·역압력·정확한 예산 계측은 **필요한 기반 기능**으로 유지한다.
   자체 ABI나 커널 우회가 이 개선에 필요했다는 주장은 하지 않는다.
2. 공유/복사 정책만으로 Pure의 차별화를 설명하는 이번 비교 연구는 여기서 종료한다.
   남은 특수 조건을 계속 추가해서 후보 정책이 이기는 사례만 찾지 않는다.
   구체적인 새 작업이나 요구 조건이 생기면 보존한 증거부터 검토한다.
3. 다음 조사 후보는 006의 P07/P08, 즉 **큰 문서·상태에서 작은 변경에 필요한
   계산과 갱신 범위를 줄이는 문제**다. 기존 증분 구현의 잔여 비용을 먼저 확인하고,
   Pure가 바꿀 수 있는 경로가 있을 때만 기준 구현을 만든다. 전체 재계산 대비
   증분 처리의 알려진 이점을 확인하는 것만으로 새 실험을 정당화하지 않는다.
   이 후보에서도 증분 정보를 유지하는 메모리 비용을 포함해야 한다.

## 코드와 원자료

- [실험 설명·실행 방법](../../research/experiments/003-json-fanout/README.md)
- [비교 구현](../../research/experiments/003-json-fanout/benchmark.c), [실행·검증 도구](../../research/experiments/003-json-fanout/run.py)
- [실험 계획](../../research/experiments/003-json-fanout/plan.json)
- [환경·버전·빌드·소스 해시](../../research/experiments/003-json-fanout/results/2026-09-22-linux-aarch64/environment.json)
- [입력과 소비자별 출력 해시](../../research/experiments/003-json-fanout/results/2026-09-22-linux-aarch64/fixtures.json)
- [검증 기록](../../research/experiments/003-json-fanout/results/2026-09-22-linux-aarch64/verification.json)
- [210개 원자료](../../research/experiments/003-json-fanout/results/2026-09-22-linux-aarch64/raw.csv), [중앙값·범위](../../research/experiments/003-json-fanout/results/2026-09-22-linux-aarch64/summary.json)

```sh
python3 research/experiments/003-json-fanout/run.py --check-only
python3 research/experiments/003-json-fanout/run.py --output /tmp/pure-json-fanout-results
```

새 결과 디렉터리만 허용한다. 의존성은 고정 버전으로 포함되어 있어 재현 시 다운로드가 필요 없다.