Bun 1.4's bun test --parallel cuts a 4-second suite to about 1 second
Bun 1.4의 bun test --parallel, 4초 테스트를 약 1초로 단축
Bun 1.4의 bun test --parallel은 테스트 파일을 워커 프로세스에 나눠 실행합니다. 대기 시간이 긴 테스트는 코어 수보다 많은 워커로도 빨라지지만, CPU 연산이 중심인 테스트는 코어 수에서 성능 향상이 멈춥니다. 파일별 격리 범위와 --bail 동작도 실험으로 확인합니다.
- 주제
AI 요약
Bun 1.4에 추가된 bun test --parallel은 테스트 파일을 워커 프로세스에 나눠 실행합니다. 작성자는 Bun 1.4.2를 4코어 Intel Xeon VM에서 실행해, 어떤 테스트에서 병렬 실행이 효과적인지와 --isolate, --bail의 실제 동작을 살펴봤습니다.
대기 중심 테스트는 코어 수보다 많은 워커도 효과가 있습니다
20개 파일에 테스트를 두 개씩 만들고, 각 테스트가 100ms 타이머를 기다린 뒤 검증하도록 구성했습니다. 직렬 실행은 4.04초가 걸렸고, 워커 수를 2로 설정하면 2.05초, 기본값인 4에서는 1.03초가 걸렸습니다. 기본 병렬 설정만으로 약 3.9배 빨라졌습니다. 워커를 8개로 늘리자 0.63초까지 줄었습니다.
타이머를 기다리는 동안 테스트는 CPU를 거의 사용하지 않습니다. 따라서 코어 수보다 워커가 많아도 대기 중인 작업을 다른 작업으로 채울 수 있습니다. 반면 CPU 연산 성능을 확인하려고 각 테스트에서 2천만까지 소수를 찾는 체(Prime Sieve)를 실행하자 다른 양상이 나타났습니다. 직렬 실행은 6.1초, 워커 2개는 3.3초, 4개는 1.8초였습니다. 6개와 8개도 각각 1.9초와 1.8초로, 4개 이후에는 개선되지 않았습니다. CPU를 계속 쓰는 작업은 코어 수를 넘어 워커를 늘려도 빨라지지 않습니다.
작성자는 타이머를 확인하는 바쁜 대기 루프(Spin Loop)로 CPU 작업을 흉내 내려다 잘못된 결과를 얻었다고 설명합니다. 루프가 벽시계 시간을 확인하면 운영체제가 프로세스를 잠시 중단해도 시간이 지난 뒤 다시 실행될 때 종료할 수 있습니다. 실제 계산처럼 CPU를 계속 요구하지 않으므로 대기 작업과 비슷하게 동작합니다. CPU 부하를 측정하려면 시간 확인 없이 계산을 수행해야 합니다.
--isolate는 파일마다 상태를 초기화합니다
문서에는 --parallel이 --isolate를 적용한다고 적혀 있습니다. 작성자는 모듈 최상위 변수에 카운터를 두고 여러 파일에서 값을 확인했습니다. 직렬 실행에서는 파일마다 하나의 프로세스와 모듈 캐시를 공유해 카운터가 1, 2, 3, 4로 증가했습니다. 병렬 실행에서는 네 파일 모두 1을 봤습니다. 네 파일이 같은 워커 프로세스에서 실행된 경우에도 결과는 같았습니다. 프로세스를 나누는 것만이 아니라 파일마다 새로운 JavaScript 전역 객체를 제공하기 때문입니다. --no-isolate를 함께 쓰면 직렬 실행처럼 값이 이어졌습니다.
격리는 테스트마다 적용되지 않습니다. 같은 파일 안의 테스트 세 개는 카운터를 1, 2, 3으로 공유했습니다. 테스트마다 모듈 상태가 새로워야 한다면 --isolate만으로는 부족합니다. Bun은 워커의 1부터 시작하는 번호를 BUN_TEST_WORKER_ID와 JEST_WORKER_ID 환경 변수에 넣습니다. Jest 설정을 옮길 때 데이터베이스 이름이나 포트를 워커별로 지정하는 데 쓸 수 있습니다.
--bail은 실행 중인 파일을 끝까지 실행합니다
작성자는 첫 파일에서 300ms짜리 테스트가 실패하도록 구성했습니다. --bail 없이 병렬 실행하면 20개 파일이 모두 실행됐습니다. --bail을 추가하면 네 파일이 실행된 뒤 중단됐습니다. 실패가 확인됐을 때 이미 네 워커가 실행 중이었고, 나머지 16개 파일은 시작하지 않았습니다. 이 실험에서는 실행 시간이 1.53초에서 0.32초로 줄었습니다. 다만 시작되지 않은 파일의 다른 실패까지 확인하지는 못하므로, 결과를 해석할 때 이 점을 고려해야 합니다.
그 밖에 확인한 동작
세 파일에서 각각 다른 함수를 테스트한 뒤 --coverage 결과를 비교하자, 직렬 실행과 병렬 실행의 함수 커버리지는 75%, 라인 커버리지는 60%로 같았습니다. --parallel=0, --parallel=-1, --parallel=abc처럼 잘못된 값은 테스트를 시작하기 전에 오류 메시지와 종료 코드 1을 반환했습니다. 파일 하나가 모듈을 불러오는 중 충돌해도 같은 배치의 다른 파일은 실행됐으며, 충돌은 단언 실패와 구분된 오류로 표시됐습니다. 실행 배너의 4x PARALLEL 문구에서 실제 워커 수도 확인할 수 있습니다.
작성자의 권고는 테스트가 기다리는지 계산하는지에 따라 워커 수를 정하는 것입니다. 타이머, 소켓, 데이터베이스 응답을 기다리는 테스트가 많다면 코어 수보다 높은 설정도 직접 측정해볼 만합니다. CPU 계산이 많은 테스트는 기본값처럼 코어 수에 맞추는 편이 낫습니다. 어느 경우든 --isolate가 초기화하는 범위는 JavaScript 상태와 파일 사이에 한정됩니다. 고정 포트, SQLite 파일, 임시 디렉터리 같은 외부 자원을 여러 워커가 공유하면 충돌할 수 있으므로 실제 테스트 스위트를 반복 실행해 확인해야 합니다.
dev.to 반응
- @anh_nguynvn_0478e614ba — CPU 중심 테스트에서 성능이 정체되는 이유는 컨텍스트 전환 오버헤드로 설명할 수 있습니다. 물리 코어 한계에 닿으면 워커를 더 늘릴수록 운영체제가 테스트 코드를 실행하기보다 프로세스를 CPU에 넣고 빼는 데 더 많은 시간을 씁니다. Jest와 Vitest에서도 비슷한 현상을 봤습니다. 무거운 수학이나 데이터 처리 테스트라면 워커를 많이 늘리는 것보다 순차 실행하거나 수를 줄이는 편이 효율적일 때가 있습니다.
- @alexgeorgiev17 — 코어 수가 한계라는 데 동의합니다. 재미있는 점은 4개를 넘겨도 실제로 크게 느려지지 않았다는 겁니다. 4개에서 1.8초, 6개에서 1.9초, 8개에서 1.8초였으니 제 환경에서는 컨텍스트 전환 비용이 거의 측정 오차 수준이었습니다. 수학 연산 테스트도 직렬로 되돌리지는 않겠습니다. 같은 테스트가 직렬로는 6.1초, 워커 4개에서는 1.8초였습니다. 코어 수를 넘기면 확장이 멈출 뿐, 병렬 실행의 효과가 사라지지는 않습니다. 기본 설정은 이미 CPU 수를 쓰니 더 늘리지 않으면 됩니다.
- @arhancanli — I/O 중심 표는 간단한 모델로 설명할 수 있고, 성능 곡선이 어디서 끝나는지도 보여줍니다. 파일마다 100ms 테스트 두 개를 순서대로 실행하므로 파일 하나에 약 0.2초가 듭니다. 파일 전체가 스케줄링 단위이므로 실행 시간은 ceil(20/워커 수) × 0.2초입니다. 워커 4개면 5회 실행으로 1.0초이고, 측정값은 1.03초였습니다. 8개면 3회 실행으로 0.6초이고, 측정값은 0.63초였습니다. 따라서 곡선은 매끄럽지 않고 계단형입니다. 워커 7개는 8개보다 이득이 없고, 10개면 0.4초, 20개면 최소 0.2초가 됩니다. 추가로 파일 안의 테스트끼리 의존하지 않는다면 파일 안에서 동시에 실행해 작업 단위를 줄일 수 있습니다. 코어 수를 넘긴 뒤에는 고정 포트, SQLite 파일, 임시 디렉터리 같은 공유 상태가 먼저 문제를 일으킬 수 있습니다. --isolate는 이런 자원을 초기화하지 않습니다. CI에서 문제가 생기기 전에 --parallel=8로 여러 번 실행하고 실패 결과를 비교해보는 것도 좋습니다.
- @alexgeorgiev17 — 계단형 모델이 제가 글에서 그린 부드러운 곡선보다 훨씬 잘 맞습니다. 실제로는 ceil(20/워커 수)회 실행이고, 1.0초와 0.6초에 약 30ms의 고정 오버헤드가 더해진 셈입니다. 그 오버헤드는 워커 생성이나 커버리지·결과 병합 때문일 수 있고, 한 번 실행에 가까워진 뒤에는 그 부분을 측정해볼 만합니다. 7개와 8개의 차이가 없는 점도 재미있습니다. 이 모델대로라면 10개가 다음으로 실제 개선을 만드는 설정입니다. 10개와 20개는 테스트하지 않았으니 후속 실험으로 좋겠습니다. 4코어에서 20개 워커로 0.23초에 가까운 결과가 나온다면 I/O 테스트가 거의 전부 대기 작업이라는 점을 잘 보여주겠습니다. 파일 하나를 더 추가했을 때 실행 회차가 통째로 늘어날 수 있다는 점도 설정을 맞출 때 유의해야 합니다. 파일 안에서 동시 실행하는 방법에도 동의합니다. 프로세스를 더하는 것보다 실행 단위를 줄이는 편이 부담이 적습니다. 다만 같은 파일의 테스트는 서로 공유하는 상태가 더 많을 수 있습니다. 공유 상태 문제가 가장 중요한 교훈입니다. --isolate는 JavaScript 상태만 초기화하므로 고정 포트나 SQLite 파일을 두 워커가 함께 쓰면 여전히 실패할 수 있고, 깔끔한 오류가 아니라 간헐적 실패로 나타날 수 있습니다. --parallel=8로 12회 실행한 뒤 결과를 비교하는 방법은 저렴하고 유용해 보입니다.
원문: dev.to / 번역·요약: Trawling