CWIST의 최적화 및 비동기 전환기

by gg582 · 2026-08-15 15:46:54 · 52 views

Table of contents

CWIST의 최적화 및 비동기 전환기

우선 이 글은 정제된 기술 문서가 아닙니다. 지나가는 면접관이나 채용 담당자 님이 계신다면 유머 코드구나 생각해 주세요.

최근 CWIST를 만들면서 나름 지연 시간 측면에서 장점이 있고, 동시성 처리만 보강하면 Axum과도 겨룰 수 있겠다는 근거없는 자신감이 들었습니다.

그리고 한 번은 Axum과 정말 비등한 성능이 나왔지만, 두 번은 재현되지 않았습니다. 뭔가 황금알을 낳는 거위가 될 것 같은데, 황금알보다 그냥 알만 많이 나오는 이런 답답함을 '거위의 배를 갈라서' 해결, 즉 다 버리고 Axum으로 블로그 엔진을 재작성해서 해결하자니 솔직히 말해서 아주 아깝더라구요.

제 판단은 CWIST를 '밥 잘먹고 운동도 잘 하고 책도 잘 읽고 서울대에 간 학생'처럼 만드려 한 것이 아닙니다. '학벌도 없고, 지성도 없고, 운동도 잘 못 하지만 한 가지만 집중해서 열심히 하는 학생'을 모토로 삼은 것에 가깝죠. 어떻게 보면 제 학창시절을 보는 것 같습니다. 고등학교 시절 친구들이 다른 건 그닥 집중을 못 하는데 코딩은 정말 하루종일 집중해서 하는 것을 보며, 요상한 놈이지만 코딩은 진심이라고 했던 기억이 납니다.

만드는 사람을 닮아서인지 CWIST도 '스프링처럼 하나만 있어도 CMS부터 대시보드, 고성능 서버까지' 다 해 주기 위한 프레임워크가 아니고, '적당히 예쁘고, 적당히 크고, 나머지는 다 평균 수준이지만 추상화 뒤에서 빠르게 개발하고 프로토타입을 올려도 지연 시간 하나만큼은 Axum 이상인 놈'이 되려고 합니다. 그리고 실제로도 지연 시간의 최댓값마저 아무리 튀어도 30-50 ms 안에서 통제되는 편이구요.

그런데, 최근 결과들을 보면 동시 처리량도 Axum과 비교해서 20% 정도 차이밖에 나지 않았습니다. 개발하는 입장에서는 당연히 욕심이 나죠. Axum이 10만 RPS일 때, CWIST는 9만 RPS...이걸 보고 두 마리 토끼를 다 잡아보겠다는 생각이 안 들기가 어려울 겁니다. 그리고 만약 로봇 등에 들어가기 위한 웹 대시보드라면, Rust로 짜는 것보다 C/C++로 짜는 게 레거시 호환에 좋을 겁니다. CWIST가 대단해지지 못하더라도, 장난감 기판에 리눅스를 올려서 뭘 해 볼때도 쏠쏠하잖아요?

그렇다면 우리가 생각할 수 있는 것은 정말 명확합니다. '어차피 지금도 사용자가 10000명 몰려도 100 메가바이트를 겨우 쓰는 웹 서버인데, 비동기 처리를 늘려서 램을 더 먹는다고 한들...웹 서버 올릴 정도의 기판이면 이미 512MB-1GB의 RAM을 가지지 않겠어?'

하지만 저는 1차적으로 뭔가 놓쳐 버렸습니다.

제가 한 조치는 고작 이 정도였습니다.

  • HTTP 파싱을 SIMD를 이용해 벡터화하고, 분기 등을 최적화함
  • Rust 기반의 리액터를 만들지만, 쓰레드가 다 차면 대기하는 문제는 방치하자

판단, 판단을 보자!

뒤의 안일한 판단이 문제라고 판단했습니다. 그래서 일단 AI한테 기본 최적화 코드를 만들게 한 후, 저는 이벤트 기반으로 데이터 처리가 완료되면 스레드를 블로킹하지 말고 반환시킨 다음, 다른 애가 쓰게 하라고 지시했습니다.

그런데, 제 HTTP 풀 1개는 기본값이 1개의 풀마다 모든 코어를 차지합니다. 즉 여러 풀을 만들 거면 코어 조건을 튜닝해야 하고, 기본으로 쓰려면 풀은 1개만 만들어야 합니다.

놀랍게도 AI는 1개의 풀을 1개의 쓰레드를 점유하는 풀로 속단하고, 또 코어 수만큼 풀을 만들었습니다. 요즘같이 대단한 성능의 AI들도 사람이라면 하지 않을 실수를 해 버렸다는 것이죠. 그렇게 되면 이 프레임워크는 N개의 코어에 대해서 f(N)=N^2개 작업을 동시에 수행할 수 있다고 가정하면서 작업을 하게 되는 겁니다.

실제로 코어가 12개라고 해 봅시다. 그리고 풀이 12개, 각 풀이 12개의 동시 작업을 처리할 수 있다고 "믿고 있습니다".

각 풀에서는 12개의 작업이 들어와, 1개만 처리됩니다.

그리고 12개를 모두 활용하는 상태를 1이라고 하고, 실제 동시에 수행되는 작업 개수를 M이라고 합시다. 활용도를 A라고 하고, 이를 M/N으로 정의합니다.

그렇게 하면, 각 풀마다의 활용도 A는 1/12가 됩니다.

그리고, 각 활용도가 만드는 영향도를 생각해 봅시다. 물론 이것이 엄밀하게 수학적이지는 않지만, 상호 영향을 고려해서 각 활용도의 곱을 마이너스 1제곱한 것을 영향도라고 하고, 이것을 B라고 해 봅시다.

영향도 B는 B=(A^N)^{-1}=A^{-N}입니다.

그렇게 하면, 영향도는 8916100448256이 됩니다.

2쓰레드일 땐 고작 4의 영향도, 4쓰레드일 때는 증가해서 256의 영향도... 그리고 쓰레드가 커질 수록 영향도는 높아집니다.

하지만 실제 이 오류가 만들어 낸 격차는 생각한 것보다 심하지 않았습니다.

  • CWIST: 107567 req/s | Latency 0.07ms | RSS 14508KiB | Csw 0
  • Axum: 110438 req/s | Latency 3.56ms | RSS 15436KiB | Csw 0

생각보단 준수합니다. 그러나 이것은 OS가 기본적인 문제들에 대해서 대응책을 마련했을 뿐입니다.

하지만 또 문제가 있습니다. 이론적으로는 커널 큐를 한 개 두고, 데이터가 끝나면 그냥 스레드 점유를 끝내면 되겠구나 생각합니다. 진짜 그렇게 해 봤습니다.

  • CWIST: 154100 req/s | Latency 2.66ms | RSS 12112KiB | Csw 0
  • Axum: 151347 req/s | Latency 2.59ms | RSS 15428KiB | Csw 0

CWIST? 이럴 거면 그걸 왜 써야 하지?

딱 보고 드는 생각이 있습니다. 그럴 거면 왜 CWIST를 쓰는데? Axum이 쓰기도 더 편하잖아.

맞습니다. CWIST의 몇 안되는 차별점이었던 초저지연이 사라지고, 별로 실익도 없는 학습용 프레임워크의 전형으로 전락했습니다.

결국 이걸 해결하려면 CS적으로는 더러운 결론을 가져가게 됩니다.

커널 큐는 각 쓰레드마다 줘서 단일 락에 기대게 하지 말자. 소켓에 다음 패킷이 들어오고 있으면 왜 반환을 하는데? 잠깐 확인하고 쓰고 있으면 잡아놔!

게다가 구조 문제를 최신 기술로 덮으려고 한다고 비판할 패치도 들어갔습니다.

ACK 지연을 줄여 보기 위해 새로 받아들인 소켓에 TCP_QUICKACK를 적용했습니다.

특히 C/C++ 진영에서 저와 같은 엔트리 레벨의 신규 학습자들은 "CS주의자", 즉 일종의 과학만능주의가 되기도 합니다. CS주의자들이 보면 이 패치에 기함할 것입니다.

그러나 과학만능주의에 대해서 미하일 바쿠닌의 "신과 국가"에서 사용한 유명한 비유를 가져오겠습니다. 이것은 "프로크루스테스의 침대"입니다.

프로크루스테스는 행인을 잡아다가 잔인한 놀이를 즐겼습니다.

침대보다 키가 작으면 사람을 잡아 당겨 키를 늘리고, 침대보다 키가 크면 발목을 자른다.

CS적으로 우아하지 않다고 정말 더 정갈하게 작동할 수 있는 예시를 포기하는 것은 일종의 프로크루스테스의 침대입니다. 그렇기 때문에 저는 CS적으로도 더럽고, 유닉스/벨 연구소의 전통과도 맞지 않는 이 괴상한 패치를 굳이 넣기로 한 것입니다.

만약 당신이 몇 밀리세컨드의 지연이라도 아껴야 하는 서버를 짭니다. 모종의 이유로 당신의 기계는 하드웨어 정보를 JSON을 이용해서 REST API로 서빙합니다. 왜 CoAP으로 쏴 보내지 않냐고 할 수도 있습니다. 하지만 실제로 어떤 경우에는 저지연 REST 서버가 필요합니다.

내노라하는 프레임워크들을 써도 2 ms, 3 ms 등 고정 지연이 발생할 때, 어떤 프레임워크들은 0.2 ms 수준의 초저지연을 추구해 줘야 하고, 많은 C/C++ 프레임워크는 이런 목적으로 개발됩니다.

  • CWIST: 107283 req/s | Latency 0.11ms | RSS 14368KiB | Csw 0
  • Axum: 108362 req/s | Latency 3.55ms | RSS 15452KiB | Csw 0

하지만, CWIST는 그 프레임워크들보단 추상화되어 있고, 같은 목표를 따라야 합니다. 만약 이것이 Java로 짜인 마이크로 프레임워크였다면, Axum보다 많은 처리량 선에서 끝내고 안정 버전 릴리스와 함께 처음으로 코드 네임을 부여했을 것입니다. 하지만 C에 사람들이 기대하는 것은 "Rust를 쓰지 않고 레거시를 끌고 가더라도 맛볼 수밖에 없는 감칠맛"입니다. 그리고 작성자인 저 본인조차도 그걸 바라고 있구요.

우선 멀티 프로세스 특성을 고려하면 아까의 자원 관리 모델이 무너집니다.

N개의 코어에 대해 적절하게 배분되어 있다고 "주장하는" M 개의 프로세스가 있습니다.

실제 각 프로세스의 활용도는 1/M입니다. 이것을 개별 활용도 A라고 합니다.

이 개별 활용도의 영향도 B를 산출하면 B는 M^M입니다.

이런 M^M의 계산을 해 봅시다.

2코어에서 4, 4코어에서 256, 12코어에서 8916100448256로 가파르게 증가합니다.

다시 아까같은 패턴으로 영향도가 폭증하며 고성능 시스템일수록 강한 병목이 생깁니다.

물론 여기서 '네가 맘대로 정의한 영향도이지 않나' 물을 수도 있습니다.

그러나, 애초에 그런 식으로 걸고 넘어지기 시작하면 디버깅을 위해서 수학과를 고용해야 합니다.

개인이 아타락시아를 추구하며 나의 지표를 만들 여유는 있지만, 사람을 사람 아래에 고용할 자본 따위 없습니다.

초저지연, 좋다 이겁니다. 하지만 Axum보다 초당 1079개의 요청을 덜 처리합니다. 스타가 많은 것도 아니고, 인상깊은 성능이 있지도 않은 이 프레임워크를 포트폴리오로 인정해 주려면, Axum보다는 많은 요청을 처리해야 C답다는 얘기를 듣습니다.

가장 황당한 것은, 메모리 할당을 최소화하고, 온갖 경로를 최적화해도 "성능이 개선되지 않고 정체된다"는 지점입니다. 이번엔 로컬 환경에서 균일하게 측정하였을 때는 개선되었으나, CI에서는 오히려 Axum과의 차이가 1%->6%로 벌어졌습니다. 즉, CI 환경처럼 자원이 제한되면 CWIST는 크게 흔들립니다.

  • CWIST: 106423 req/s | Latency 0.09ms | RSS 14432KiB | Csw 0
  • Axum: 112889 req/s | Latency 3.48ms | RSS 19056KiB | Csw 0

왜 CWIST를 써야만 한다는 거야?

또 다른 비판과 싸워 이기겠어

일단 이번에는 우아한 패치와 더러운 패치를 모두 쓰겠습니다.

첫 번째는 반복되는 고정 응답을 처리하기 위해 만든 "Big Dumb Reply"를 개선합니다.

이것은 크고, 반복되고, 고정적인 응답을 그 때마다 동적으로 할당 및 생성하지 않고, 응답을 캐싱해 기계적으로 소켓에 밀어 넣기 위한 기능입니다.

그러나, 이 캐싱 정보를 읽을 때 뮤텍스를 쓰고 있었습니다. 듣자마자 한 가지 생각이 드셨을 겁니다.

대체 왜?

여기서 "원래 그렇지" 처럼 넘기면 안 됩니다. 노래 가사에서는 "C'est la vie"라던가, "흐르는 강물처럼"이라고 할 수 있습니다. 하지만 이 뮤텍스는 Atomic 플래그를 이용해서 개선할 수 있습니다.

일단 이걸 개선하는 것은 사실 아주 큰 향상을 주지 못합니다.

두 번째, 예전에 땜빵으로 넣어 둔 get_cpu_cores() 함수의 하한입니다. 하한으로 4를 두어 2코어 환경에서도 항상 4를 반환했습니다. 이것도 빼 줍시다. 영향도는 이론적으로 줄지만 아까 반복적으로 관찰했듯 큰 폭의 변화를 못 만듭니다.

여기서 더러운 최적화는 하지 말자고 단념하면 위대하신 대기업, 비영리기관들의 말놀음대로 따라주는 결정을 하게 됩니다. 솔직히 말하자면 FFmpeg는 거의 어셈을 때려 박아 최적화했고, 많은 고성능 라이브러리들은 GCC 플래그를 비틀어 '빨라졌다! 어쨌든 개선이다! 우끼끼' 수준의 멍청한 최적화를 눈물을 머금고 감행합니다. 그리고 이게 "엘리트들의 이해하기 힘든 위대한 결심"으로 포장되다니, 예고르 레토프 노래에 나오는 반복되는 후렴이 생각납니다.

"모든 것이 계획대로(Все идет по плану)"

몇 마디 덧붙이겠습니다. 개인 개발자면 못 합니까? 벤처 기업이면 못 합니까?

그들의 계획에 나 자신을 잘라 맞춰야 합니까? 솔직히 말해 봅시다. 위대하신 메타, 테슬라, 구글이 내 직장은 아니지 않습니까? 글로벌 기업이 공산당에 우리가 '무장공비'나 '북한 인민군'이라도 되는 것도 아니구요. 우리는 대한민국 사람이지 저기 북괴 사람은 아니지 않습니까?

필요하면 하면 됩니다. 그리고 지금 이 순간 그 결정은 필요합니다.

이제는 '퇴폐미가 넘치는' GCC 플래그 최적화를 하겠습니다.

-fno-plt로 간단하게 간접 호출 점프 오버헤드를 정리합니다.

-falign-functions=32 -falign-loops=32로 루프와 함수 진입점을 32바이트 경계에 맞춰 정렬합니다.

-fno-semantic-interposition -finline-functions -fipa-pta를 이용해 심볼 인터포지션 제약을 줄이고, 인라이닝과 프로시저 간 분석을 더 공격적으로 허용합니다.

CWIST의 특성과 수학 연산에 의존하는 로직 여부가 있나 봅시다.

없습니다.

-Ofast를 넣어서 부동 소수점 정확도를 희생해 루프 벡터화와 수치 최적화를 한다고 흔히 배웁니다.

CWIST가 실수형의 부동 소수점 연산에 의존합니까? 아닙니다.

CWIST에서 쓰지도 않는 부동 소수점, 그리고 괴상한 엣지 케이스에서 발생 가능한 '어쩌라고' 싶은 규약 파괴에 대해서 수학적으로 100% 정직함을 보장해야 하는 비즈니스 제품도 아닌, 고성능 추구형 오픈 소스에서 왈가왈부하면 광대 놀음입니다.

못 넣을 것도 없죠. 그냥 -Ofast 합시다. CWIST는 헤더까지 다 포함해도 26967줄밖에 안 되고, BoringSSL, lsquic 등의 주요 라이브러리들이 적당히 다 만들어 준 것을 빌어 와서 적재적소에 배치하고, CWIST 자체의 로직은 비교적 순수한 분기문, 정수 연산 등에 의존합니다.

그럼 대체 왜 -Ofast가 쥐약입니까? 괴상한 레거시 기기에 CWIST를 넣을 것이 아니라면, 그냥 해 두고 문제가 생기면 30000줄도 안 되는 코드만 분석하면 됩니다.

아쉽게도, clang 빌드를 위한 최적화에 착수하기도 전에, 격차는 줄었으나 여전히 Axum보다 느리다는 것을 확인했습니다.

Axum의 216712.66 req/s에 비해 CWIST의 208164.44 req/s는 4% 가량 느립니다.

아까보다 빨라진 거 아니냐고 물을 수도 있습니다. 하지만 GitHub Actions 내 벤치마크는 편차가 심해서, 절대적 성능 지표로는 가치가 낮고 상대치에서 가치가 큽니다. 아까는 Axum보다 최대 6% 느렸습니다. 일시적으로 Axum과의 차이가 1% 수준이었던 재현 불가능한 세션을 고려하지 않는다면 실제로 로컬에서도 러너에서도 비슷한 폭의 성능 향상이 있었으니, 유효하긴 한 판단입니다.

하지만, C/C++에서 여기서 멈추면 아마추어 소리도 못 듣고 끝납니다.

더 해야 합니다. 격차를 벌릴 때까지.

불필요한 호출을 줄입니다. 이렇게요.

 
diff --git a/src/net/http/async_server.c b/src/net/http/async_server.c
index 61eb521..4899c3a 100644
--- a/src/net/http/async_server.c
+++ b/src/net/http/async_server.c
@@ -37,9 +37,6 @@ static void async_accept_cb(int fd, void *ctx) {
         int quickack = 1;
         setsockopt(client_fd, IPPROTO_TCP, TCP_QUICKACK, &quickack, sizeof(quickack));
 #endif
-        struct timeval tv = { .tv_sec = 5, .tv_usec = 0 };
-        setsockopt(client_fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
-        setsockopt(client_fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
 
         if (app_use_https(app)) {
             https_pool_submit(client_fd, app->ssl_ctx, app->https_request_handler, app);

앞으로도 이런 기능이 설명되지 않는 코드는 지속적으로 지워 줘야 합니다.

그리고 비정상 요청에 대해서는 클라이언트 파일 디스크립터를 빨리 닫아서 자원 점유를 빨리 풀어 줍니다.

이외 몇 가지 스타일 정리와 Clang 지원을 완성한 후, 다시 벤치마크를 돌립니다.

  • CWIST: 110546 req/s | Latency 0.08ms | RSS 14512KiB | Csw 0
  • Axum: 108521 req/s | Latency 3.59ms | RSS 14156KiB | Csw 0

물론 이 벤치마크 결과는 Axum보다 고작 1.9% 정도 빠릅니다. 그리고 항상 CWIST 앱이 Axum보다 빠를 것이라고 보장하지도 못합니다.

하지만 적어도 이 경우에는 큰 명분을 얻습니다.

Axum에 밀리지 않고, 생각보다 더 낮은 레이턴시를 가진 괜찮은 오픈 소스

그리고, 명분에 대해서 '명분론은 죽었다'고 비판하지만, 그 명분이 시사하는 바는 엄청납니다.

이로써 CWIST는 비로소 명분을 얻었고, 완성형 오픈 소스 프레임워크의 첫 발을 뗐습니다.

우로보로스, 셀프 루프의 완성

자, 그럼 또 스스로 논거를 반박해 볼까요?

일단 현재 진행 중인 WASM 호환성 건에 대하여 Makefile 보강을 하는 김에, 같은 소스가 두 번째 벤치에서 다시 "Axum에게 밀리는가"를 보려고 합니다.

만약 여기에서도 크게 밀리지 않으면, "Axum에 비해 한참 불안한 쓰레기" 딱지는 조금이나마 면할 수 있습니다. 여기서 튀면요? 다시 다음 포스트에서 벤치마크를 비교해 가며, 스스로 코드 리뷰를 무한 루프를 돌리며 뭔가 해야겠죠.

에반게리온이냐? 혼의 루프라도 돌게?

사실 그런 건 없습니다. 백이면 백, 동작이 명분을 밝히는 기본적인 논리를 따라 갑니다.

동양철학에서 지루할 정도로 반복되는 신화가 있습니다.

태극이 양의를 낳고, 양의가 사상을 낳고, 사상은 팔괘를 낳고, 팔괘는 64괘를 낳는다.

2^0 to 2^1 to 2^2 to 2^3 to 2^6

여기서 이 기본 논리가 밑이나 다름 없다고 가정합니다.

그럼 아무리 내세우려는 명분이 좋아도, 실제로 그러하지 못하면 붕괴합니다.

밑이 1이라고 하면 아무리 거듭제곱해도 1이니까요.

그럼 스스로 성찰하기의 기막힌 루프를 시작해 볼까요?

바로 결과가 나왔습니다.

  • CWIST: 111166 req/s | Latency 0.08ms | RSS 14648KiB | Csw 0
  • Axum: 114782 req/s | Latency 3.39ms | RSS 16148KiB | Csw 0

CWIST가 3.15% 느림.

전보단 낫지만, 저는 아무래도 다음 시간에 와야겠습니다.

Back

Comments

No comments yet.