겉보기엔 무관한 변경으로 벤치마크가 회귀한 것처럼 보이는 이유를 추적한 조사: GitHub Actions에서 서로 다른 CPU가 배정되며 glibc의 하드웨어 기능 탐지가 성능 변동을 만든다.
CodSpeed에서는 겉보기엔 무관한 코드 변경을 했는데도 벤치마크가 회귀했다는 보고를 가끔 받습니다. 흔한 예로는 문서 업데이트, 다른 CI 워크플로 변경, 벤치마크 추가/삭제 등이 있습니다.

이 글은 최근 실제로 그런 일이 있었던 한 조사를 더 깊게 파고들어 보려는 목적을 갖고 있습니다.
한 사용자가 새 벤치마크를 추가했고, PR에서 CodSpeed 리포트가 겉보기엔 관련 없어 보이는 다른 벤치마크들의 성능 회귀를 보여줬다고 보고했습니다.

새 벤치마크만 추가했는데도 회귀한 벤치마크 8개를 보여주는 CodSpeed PR 댓글
코드 diff는 대략 이런 모습이었습니다. 새 벤치마크 하나만 추가된 상태였죠.
fn bench_foo() {
foo()
}
fn bench_bar() {
bar()
}
fn bench_baz() {
baz()
}
새로 추가된, 그리고 무관한 bench_baz 함수가 어떻게 bench_foo의 성능에 영향을 줄 수 있을까요?
프로그래머로서 우리는 추상화로 사고하도록 훈련되어 있습니다. 함수 foo가 10ms 걸리고, 여기에 다른 함수를 하나 더 추가했다면, 당연히 성능에 영향을 주지 않아야 한다고 생각하죠.
하지만 실제로 CPU는 우리가 생각하는 것보다 훨씬 복잡합니다. 최고 성능을 달성하기 위해 캐시, 스레딩, 분기 예측 등 수많은 기법을 사용해 마지막 한 방울의 성능까지 짜냅니다. 그런데 곧 보겠지만, 이런 점 때문에 예상치 못한 동작이 나타나기도 합니다.
이 문제를 일으키는 원인을 파악하려면, 먼저 성능이 어떻게 측정되는지 이해해야 합니다. 우리는 바이너리를 계측하고 캐시 성능을 분석하기 위해 Callgrind(Valgrind 도구)의 약간 수정된 버전을 사용합니다.
먼저, 시뮬레이션 모드(Valgrind 사용)로 언어별 CodSpeed 통합을 통해 바이너리를 빌드해야 합니다. Rust에서는 CodSpeed 지원을 포함해 벤치마크를 빌드하기 위해 cargo-codspeed CLI를 사용할 수 있습니다.
cargo codspeed build -m simulation
그 다음에는 codspeed CLI로 벤치마크를 실행합니다.
codspeed exec -- cargo codspeed run -m simulation
내부적으로 우리는 예를 들어 캐시를 설정하는 등 필요한 인자와 함께 callgrind를 호출합니다. 그에 더해, 시작 시점에는 callgrind를 비활성화해 두고 벤치마크 라이브러리 내부에 특정 계측을 넣어 벤치마크 코드만 측정되도록 보장합니다. 이렇게 하면 이미 많은 노이즈가 제거됩니다. 이는 대략 아래와 같은 것과 비슷합니다.
valgrind \
--tool=callgrind \
--instr-atstart=no \
--cache-sim=yes \
... \
-- cargo codspeed run -m simulation
Valgrind를 실행해 성능을 측정한 뒤에는, 캐시 미스, 데이터 읽기/쓰기 등 벤치마크 실행 결과를 담은 여러 개의 .out 파일을 얻습니다. 이 데이터는 벤치마크가 소비한 사이클과 총 시간을 추정하는 데 사용할 수 있습니다.
Callgrind는 가상 CPU에서 코드를 실행하며, 그 장점은 (적어도 이론상) 실행이 완전히 결정적(deterministic)이라는 점입니다. 하지만 디스크 읽기, 네트워크 호출, 또는 syscall이 있으면, 에뮬레이션되지 않는 외부 요인에 따라 성능이 달라질 수 있어 분산을 도입하게 됩니다.
벤치마크가 결정적인지 확인하기 위해, 벤치마크를 한 번 컴파일한 뒤 같은 머신에서 100번 실행했습니다. 결과는 다음과 같습니다.
| Benchmark | RSD | Mean (μs) | Median (μs) |
|---|---|---|---|
bm_Coro_CoAwait_ImmediateCoroutine | 0% | 5.719 | 5.719 |
bm_Coro_CoAwait_ImmediatePromise | 0% | 5.435 | 5.435 |
bm_Coro_Immediate | 0% | 3.718 | 3.718 |
bm_Coro_Pow2_20 | 0% | 18.248 | 18.248 |
bm_Coro_Shift_20 | 0% | 21.093 | 21.093 |
bm_Promise_Immediate | 0% | 3.084 | 3.084 |
bm_Promise_ImmediatePromise_Then | 0% | 3.751 | 3.751 |
bm_Promise_Pow2_20 | 0% | 7.569 | 7.569 |
bm_Promise_ReadyNow | 0% | 1.894 | 1.894 |
bm_Promise_Shift_20 | 0% | 8.458 | 8.458 |
우리는 전체 실행들에 걸친 분산을 확인하기 위해 상대 표준편차(RSD)를 사용했습니다. 예상대로 100% 결정적입니다.
그렇다면, 벤치마크를 다시 빌드한 뒤 서로 다른 job에서 실행하면 분산은 어떻게 될까요?
이제 같은 실험을 하되, 각 실행을 서로 다른 job에서 수행해 보겠습니다. GitHub Actions에서는 matrix를 사용해 이를 쉽게 할 수 있습니다. 우리는 0이 아닌 분산이 있는 경우에만 관심이 있으므로 10번 실행부터 시작했습니다. 모든 실행이 분산이 없다면 통계적으로 유의미함을 확인하기 위해 실행 횟수를 100으로 올릴 수 있습니다.
benchmarks-parallel:
name: Run benchmarks
runs-on: ubuntu-24.04
strategy:
matrix:
iteration: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
steps:
- uses: actions/checkout@v5
- name: Build benchmarks
run: ...
- name: Run benchmarks (iteration ${{ matrix.iteration }})
uses: CodSpeedHQ/action@v4
with:
run: ...
mode: simulation
집계 결과는 다음과 같습니다.
| Benchmark | RSD | Mean (μs) | Median (μs) |
|---|---|---|---|
bm_Coro_CoAwait_ImmediateCoroutine | 0% | 5.719 | 5.719 |
bm_Coro_CoAwait_ImmediatePromise | 0% | 5.435 | 5.435 |
bm_Coro_Immediate | 0% | 3.718 | 3.718 |
bm_Coro_Pow2_20 | 0.1% | 18.3 | 18.306 |
bm_Coro_Shift_20 | 0.505% | 21.401 | 21.435 |
bm_Promise_Immediate | 0% | 3.084 | 3.084 |
bm_Promise_ImmediatePromise_Then | 0.244% | 3.777 | 3.78 |
bm_Promise_Pow2_20 | 0.121% | 7.596 | 7.599 |
bm_Promise_ReadyNow | 0% | 1.894 | 1.894 |
bm_Promise_Shift_20 | 0% | 8.458 | 8.458 |
갑자기 분산이 생겼습니다! 흥미로운 인사이트이긴 하지만, 가능한 설명은 많습니다. 예를 들면 컴파일러 비결정성, 다른 링킹 순서, 더 새로운 툴체인이나 라이브러리 등입니다.
Callgrind는 각 프로세스에 대해 callgrind.out.<pid> 파일을 생성합니다. 여기에는 어떤 함수가 실행되었는지, 얼마나 오래 걸렸는지, 어떤 비용(cost) 이 있었는지에 대한 방대한 데이터가 들어 있습니다. Valgrind에서 비용은 다음을 의미합니다.
Ir: 읽힌(그리고 실행된) 명령어 수Dr: 읽힌 데이터의 양Dw: 기록된 데이터의 양I1mr: L1 명령어 캐시 미스D1mr: L1 데이터 캐시 읽기 미스D1mw: L1 데이터 캐시 쓰기 미스ILmr: LL 명령어 캐시 미스DLmr: LL 데이터 캐시 읽기 미스DLmw: LL 데이터 캐시 쓰기 미스이번 경우에는 10번 실행에서 가장 분산이 컸던 bm_Coro_Shift_20 벤치마크를 살펴보고 있습니다. 지금은 이 벤치마크 실행의 총 비용 을 설명하는 마지막 줄만 보면 됩니다.
part: 74
desc: Timerange: Basic block 20475222 - 20483854
desc: Trigger: Client Request: src/kj/async-bench.c++::bm_Coro_Shift_20
...
events: Ir Dr Dw I1mr D1mr D1mw ILmr DLmr DLmw
totals: 20577 5984 5064 185 60 177 185 60 177
모든 실행에서 이를 비교해 차이를 확인할 수 있습니다.
| run | Ir | Dr | Dw | I1mr | D1mr | D1mw | ILmr | DLmr | DLmw |
|---|---|---|---|---|---|---|---|---|---|
| 1 | 20901 | 6024 | 5091 | 185 | 65 | 180 | 185 | 65 | 180 |
| 2 | 20901 | 6024 | 5091 | 185 | 65 | 180 | 185 | 65 | 180 |
| 3 | 20901 | 6024 | 5091 | 185 | 65 | 180 | 185 | 65 | 180 |
| 4 | 20901 | 6024 | 5091 | 185 | 65 | 180 | 185 | 65 | 180 |
| 5 | 20577 | 5984 | 5064 | 185 | 60 | 177 | 185 | 60 | 177 |
| 6 | 20901 | 6024 | 5091 | 185 | 65 | 180 | 185 | 65 | 180 |
| 7 | 20901 | 6024 | 5091 | 185 | 65 | 180 | 185 | 65 | 180 |
| 8 | 20901 | 6024 | 5091 | 185 | 65 | 180 | 185 | 65 | 180 |
| 9 | 20901 | 6024 | 5091 | 185 | 65 | 180 | 185 | 65 | 180 |
| 10 | 20901 | 6024 | 5091 | 185 | 65 | 180 | 185 | 65 | 180 |
표를 자세히 보면, 5번째 실행이 다르다 는 것을 알 수 있습니다. 어떤 이유로 실행된 명령어 수가 더 적고, 데이터 읽기/쓰기 양도 더 적으며, 데이터 캐시 미스도 더 적습니다. 어떻게 이런 일이 가능할까요?
처음에는 컴파일러가 코드를 더 잘 최적화했을 거라고 가정했습니다. 그런데 각 실행에서 빌드된 바이너리의 체크섬을 계산해 보면 결과는 이렇습니다.
$ for dir in run-{1..10}; do sha1sum "$dir/async-bench"; done
0f6aad3ccdf626b3e141a262a7907f64a1c4dbfe run-1/async-bench
0f6aad3ccdf626b3e141a262a7907f64a1c4dbfe run-2/async-bench
0f6aad3ccdf626b3e141a262a7907f64a1c4dbfe run-3/async-bench
0f6aad3ccdf626b3e141a262a7907f64a1c4dbfe run-4/async-bench
0f6aad3ccdf626b3e141a262a7907f64a1c4dbfe run-5/async-bench
0f6aad3ccdf626b3e141a262a7907f64a1c4dbfe run-6/async-bench
0f6aad3ccdf626b3e141a262a7907f64a1c4dbfe run-7/async-bench
0f6aad3ccdf626b3e141a262a7907f64a1c4dbfe run-8/async-bench
0f6aad3ccdf626b3e141a262a7907f64a1c4dbfe run-9/async-bench
0f6aad3ccdf626b3e141a262a7907f64a1c4dbfe run-10/async-bench
모든 바이너리가 같은 해시를 갖습니다! 모든 빌드를 ubuntu-24.04에서 수행했고 동일한 바이너리가 만들어졌는데, 결과는 서로 다르다니요?
거의 아이디어가 바닥난 상태에서, 문제가 있을 수 있다고 생각한 영역들을 모두 적어 보았습니다.
로그를 diff해서 실행 순서를 확인하던 중 매우 흥미로운 사실을 발견했습니다. 분산이 있던 5번째 실행은 나머지 9개 실행과 비교했을 때 캐시 크기가 달랐습니다.
Run on (4 X 3491.87 MHz CPUs)
CPU Caches:
L1 Data 48 KiB (x2)
L1 Instruction 32 KiB (x2)
L2 Unified 1280 KiB (x2)
L3 Unified 49152 KiB (x1)
나머지 9개 실행의 캐시는 다음과 같습니다.
Run on (4 X 3244.71 MHz CPUs)
CPU Caches:
L1 Data 32 KiB (x2)
L1 Instruction 32 KiB (x2)
L2 Unified 512 KiB (x2)
L3 Unified 32768 KiB (x1)
캐시 차이만으로는 차이를 완전히 설명할 수 없습니다. 이는 마지막 수준 캐시 미스가 줄어든 것은 설명하지만, 데이터 읽기/쓰기 자체가 줄어든 것은 설명하지 못합니다.
하지만 캐시가 다르다면 CPU도 달라야 합니다. 그리고 GitHub Actions에서 고정(pinned) 러너 이미지를 쓰더라도 서로 다른 CPU가 배정된다는 사실이 밝혀졌습니다. 우리 사례에서는 다음 CPU들이었습니다.
어떤 시점에 어떤 CPU가 배정되는지는 완전히 랜덤이며, 가용 리소스에만 좌우됩니다.
callgrind 트레이스를 더 깊게 살펴본 결과, 성능 차이의 대부분은 glibc의 malloc 구현 내부에서 발생한다는 것을 발견했습니다. 두 머신 모두 동일한 버전(GLIBC 2.39)을 사용하므로, 문제는 환경에 있어야 합니다.

실행 프로파일: 성능 차이 대부분이 glibc의 malloc에서 발생함을 보여줌
lscpu 명령을 사용하면 서로 다른 CPU 기능을 추출해 비교할 수 있습니다. 100개가 넘는 기능 중 대다수(77개)는 Intel과 AMD 사이에서 공유됩니다. 특정 명령어가 궁금하다면 x86 Instruction Set Reference나 Wikipedia의 x86 명령어 목록을 참고하세요.
3dnowprefetch, abm, adx, aes, aperfmperf, apic, avx, avx2, bmi1, bmi2, clflush,
clflushopt, clwb, cmov, constant_tsc, cpuid, cx16, cx8, de, erms, f16c, fma,
fpu, fsgsbase, fsrm, fxsr, ht, hypervisor, invpcid, lahf_lm, lm, mca, mce, mmx,
movbe, msr, mtrr, nonstop_tsc, nopl, nx, pae, pat, pcid, pclmulqdq, pdpe1gb,
pge, pni, popcnt, pse, pse36, rdpid, rdrand, rdseed, rdtscp, rep_good, sep,
sha_ni, smap, smep, sse, sse2, sse4_1, sse4_2, ssse3, syscall, tsc,
tsc_known_freq, tsc_reliable, umip, vaes, vme, vpclmulqdq, xgetbv1, xsave,
xsavec, xsaveopt, xsaves
Intel CPU에는 27개의 추가 플래그가 있으며, 그 대부분은 AVX-512 지원을 위한 것입니다. 다른 것들은 Transactional Memory(rtm, hle) 및 Intel Virtualization Extensions(vmx, ept, ept_ad, vpid, ...)와 관련되어 있습니다.
arch_capabilities, avx512_bitalg, avx512_vbmi2, avx512_vnni, avx512_vpopcntdq,
avx512bw, avx512cd, avx512dq, avx512f, avx512ifma, avx512vbmi, avx512vl, ept,
ept_ad, gfni, hle, la57, rtm, ss, tpr_shadow, tsc_adjust, tsc_deadline_timer,
vmx, vnmi, vpid, x2apic, xtopology
AMD에는 AMD 버전의 가상화 확장(svm, npt, vmmcall, ...), SSE4a, User Shadow Stack 지원을 위한 user_shstk 같은 보안 기능, 그리고 캐시 라인을 0으로 만드는 clzero 같은 더 구체적인 명령어까지 포함하는 25개의 커스텀 플래그가 있습니다.
arat, clzero, cmp_legacy, cr8_legacy, decodeassists, extd_apicid, flushbyasid,
fxsr_opt, misalignsse, mmxext, npt, nrip_save, osvw, pausefilter, pfthreshold,
rdpru, sse4a, svm, topoext, tsc_scale, user_shstk, v_vmsave_vmload, vmcb_clean,
vmmcall, xsaveerptr
전반적으로 Intel CPU는 성능을 크게 끌어올릴 수 있는 기능을 더 많이 제공합니다. 하지만 앞서 봤듯이 머신은 캐시 크기도 다르며, 이는 성능에 큰 영향을 줍니다. Intel이 모든 캐시에서 명확히 더 좋습니다.
| Intel Xeon 8370C | AMD EPYC 7763 | |
|---|---|---|
| L1 Data Cache | 48 KiB (+50%) | 32 KiB |
| L2 Unified Cache | 1280 KiB (+150%) | 512 KiB |
| L3 Unified Cache | 48 MB (+50%) | 32 MB |
성능이 더 빨라지는 이유 를 이해하려면, GLIBC의 소스 코드를 파고들어 어떤 트릭을 쓰는지 알아봐야 합니다.
GLIBC는 Linux 시스템에서 가장 많이 쓰이는 라이브러리 중 하나이므로, 가능한 한 빠르게 만들기 위해 엄청난 작업이 들어갔다는 것은 분명합니다. 그리고 이는 기반 시스템/CPU에 맞춰 구현을 조정(tailor)해야만 가능합니다.
예를 들어, 멀티스레드 프로그램에서 락 경합을 줄이기 위해 얼마나 많은 인스턴스가 필요한지 결정하려고 CPU 코어 수를 감지합니다. 우리 경우 두 CPU 모두 4코어이므로 이는 문제가 아니었습니다.
int n = __get_nprocs ();
if (n >= 1)
narenas_limit = NARENAS_FROM_NCORES (n);
else
/* We have no information about the system. Assume two
cores. */
narenas_limit = NARENAS_FROM_NCORES (2);
}
또한, 캐시를 오염시키는 것을 막기 위해 데이터를 메인 메모리에 직접 써야 하는지 판단하는 데 도움이 되도록 캐시 크기도 감지합니다. 이는 MOVNTI나 MOVNTQ 같은 non-temporal 명령어를 사용해 수행됩니다. 예를 들어 캐시가 8MB인데 16MB 메모리를 복사하려 한다면 캐시를 사용하지 않습니다. 하지만 캐시가 16MB라면 캐시를 사용합니다.
tunable_size = TUNABLE_GET (x86_data_cache_size, long int, NULL);
/* NB: Ignore the default value 0. */
if (tunable_size != 0)
data = tunable_size;
tunable_size = TUNABLE_GET (x86_shared_cache_size, long int, NULL);
/* NB: Ignore the default value 0. */
if (tunable_size != 0)
shared = tunable_size;
또 다른 최적화는 특화된 CPU 명령어를 감지하는 것입니다. CISC 아키텍처는 비디오 인코딩이나 암호화 같은 흔한 작업을 하드웨어 명령어로 추가함으로써 성능 향상을 얻습니다. 하지만 이런 명령어 집합 확장은 CPU마다 크게 다르며 출시 시기와 브랜드에 따라 달라집니다.
이는 서로 다른 CPU 기능을 사용하는 여러 구현을 포함한 단일 공유 라이브러리를 빌드해 두고, 런타임에 동적으로 디스패치하는 방식으로 수행할 수 있습니다. 예를 들어 Rust는 cpuid에 의존해 CPU 기능을 감지하는 is_x86_feature_detected! macro를 제공합니다.
#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
pub fn copy_memory(src: &[u8], dst: &mut [u8]) {
if is_x86_feature_detected!("avx2") {
// Use AVX2 instructions (256-bit SIMD)
// Can copy 32 bytes per instruction
avx2_memcpy(src, dst)
} else if is_x86_feature_detected!("sse4.2") {
// Use SSE4.2 instructions (128-bit SIMD)
// Can copy 16 bytes per instruction
sse42_memcpy(src, dst)
} else {
// Fallback to scalar implementation
// Copies 8 bytes per instruction on 64-bit
dst.copy_from_slice(src);
}
}
하지만 이는 바이너리를 비대하게 만들고 빌드 과정을 복잡하게 만들 수 있습니다. GLIBC 2.33+에는 새 기능이 있는데, 서로 다른 하드웨어 기능으로 라이브러리를 여러 번 빌드할 수 있게 해줍니다. 라이브러리 안에서 동적 디스패치를 하는 대신, 링커/로더가 CPU가 지원하는 최대 버전을 로드합니다.
/usr/lib/glibc-hwcaps/x86-64-v4/libfoo0.so
/usr/lib/glibc-hwcaps/x86-64-v3/libfoo0.so
/usr/lib/glibc-hwcaps/x86-64-v2/libfoo0.so
/usr/lib/libfoo0.so
이런 최적화는 GLIBC를 엄청나게 빠르게 만들지만, 불행히도 벤치마크에서는 분산을 도입할 수도 있습니다. 분산이 커지면 작은 회귀가 놓치기 쉬워지고(우리의 이전 블로그 글 참고), 시간이 지남에 따라 성능에 큰 악영향을 줄 수 있습니다. 그렇기 때문에 해결책을 찾아 분산을 고치는 것이 매우 중요합니다.
우선: 정말 고칠 필요가 있을까요? 1ms보다 오래 걸리는 매크로 벤치마크라면, 서로 다른 CPU가 도입하는 분산은 무시할 수 있는 수준일 가능성이 큽니다. 하지만 핫 패스에서 할당이나 메모리 연산을 많이 수행한다면 여전히 영향을 받을 수 있습니다.
GitHub에서는 Large Runners(더 많은 RAM, CPU, 디스크를 가진 러너)의 운영체제, 아키텍처, 이미지만 제어할 수 있고, 실제 CPU는 제어할 수 없습니다. 그럼에도 우리는 8 VCPU를 사용할 때 100번 실행에서 같은 머신을 받는다는 것을 실험적으로 확인했습니다. 하지만 절대적인 안정성이 필요하다면 CodSpeed에서는 벤치마크 실행을 위한 안정적이고 격리된 환경을 제공하도록 구성된 전용 베어메탈 머신인 Macro Runners를 제공합니다. 여기서는 벤치마크가 항상 같은 CPU에서 실행되며, 분산도 최소화됩니다.
대안으로는 GLIBC_TUNABLES를 사용해 GLIBC 기능 탐지를 꺼버릴 수도 있습니다. 이는 사용하고 싶지 않은 기능을 환경 변수로 지정하는 방식입니다. 다만 CPU 기능마다 각각 설정해야 하므로 상당히 해키하고 유지보수도 쉽지 않습니다.
$ GLIBC_TUNABLES=glibc.cpu.hwcaps=-AVX2 <your-bench-cmd>
또 다른 해결책은 callgrind를 수정해 지원되는 CPU 기능을 “스푸핑(spoof)”하는 것입니다. 현재 callgrind는 에뮬레이션 과정도 더 빨라지기 때문에 더 최신 기능을 감지해 사용합니다. 이는 가상 CPU가 서로 다른 물리 CPU에서도 동일하게 유지되므로 전반적으로 최선의 해결책이 될 수 있지만, 최소 요구 CPU 기능을 결정해야 한다거나 callgrind를 포크한 뒤 유지보수해야 한다는 등 몇 가지 트레이드오프가 있습니다. 우리는 이미 fork를 갖고 있으므로, 벤치마크를 더 안정적으로 만들기 위해 향후 구현할 가능성이 큽니다.
또는 러너가 같은 CPU를 쓰도록 강제하려 하기보다, 이를 감지하고 로그로 남겨 회귀가 이것 때문에 발생한다는 것을 인지하는 방법도 있습니다. 현재 우리는 이 접근을 선택했습니다. CPU뿐 아니라 컴파일러나 라이브러리 버전도 바뀔 수 있기 때문입니다.
이번 조사는 벤치마크 결과가 실행되는 환경에 강하게 묶여 있다는 것을 보여줬습니다. CPU 기능이나 캐시 크기처럼 명백한 요소도 있지만, 그에 대해 알지 못하면 거의 탐지할 수 없는 요소들도 있습니다.
우리는 항상 같은 CPU를 쓰기 위해 Large Runners 또는 Macro Runners 인스턴스를 사용할 것을 권장합니다. 또한 환경(CPU, 컴파일러, 라이브러리, ...)이 바뀌었을 때 이를 알려줘 회귀를 더 쉽게 식별하고 이해할 수 있게 해주는 기능도 개발 중입니다.
그리고 밝혀진 바로는, 서로 다른 CPU를 발견한 것만으로는 보고된 회귀가 해결되지 않았습니다. 흔히 간과되는 또 다른 회귀 원인을 설명할 다음 글도 기대해 주세요(힌트: 메모리 단편화와 관련되어 있습니다).