← 기사 목록으로
July 2, 2026
5분 소요

IPC 세금: 백테스트 엔진을 소켓 뒤에 두면 13%를 잃는다 — 하지만 그중 거의 전부는 소켓 탓이 아니다

IPC 세금: 백테스트 엔진을 소켓 뒤에 두면 13%를 잃는다 — 하지만 그중 거의 전부는 소켓 탓이 아니다
#algotrading
#backtest
#performance
#ipc
#rust
#architecture
Part 4 of 4 · Collection
High-Performance Backtest Engines

"환상 없는 백테스트" 시리즈의 일부입니다.

📄 이 글은 하나의 연구 논문으로 발전했습니다. 하나의 경로 의존적 백테스트 커널을 numba에서 Rust로 한 줄 한 줄 이식하고, 프로세스/언어 경계 너머로 네 가지 방식으로 호출했습니다 — 콤보별 동일한 PnL을 확인하는 동치성 게이트에 더해, 순수 IPC 레이턴시 곡선, 직렬화 세금, 스폰 비용을 각각 독립적으로 측정했습니다. 논문은 ipc-tax.marketmaker.cc에서 온라인으로(인터랙티브 버전 + PDF) 읽을 수 있으며, 코드와 데이터는 github.com/suenot/ipc-tax에 있습니다.

빨라진 백테스트 엔진은 결국 모두 같은 대화를 촉발합니다. 우리 차례도 예정대로 찾아왔습니다. 속도 사다리는 80콤보 파라미터 스윕을 pandas의 69.9초에서 단일 스레드 numba의 약 2초까지 방금 끌어내린 참이었고, 자연스럽게 다음 가려운 곳이 생겼습니다: 왜 Python JIT에서 멈추는가? 커널을 Rust로 다시 쓰자. 제대로 된 엔진 서비스로 만들자 — 소켓 뒤에 있는 컴파일된 바이너리 하나를, 모든 리서치 스크립트에서, 모든 언어에서, 그리고 라이브 트레이더에서도 호출할 수 있게. 커널 하나, 진실 하나, 중복 로직은 없이.

그리고 반론도, 역시 예정대로 도착합니다: 프로세스를 벗어나는 순간 IPC가 여러분을 잡아먹는다. 데이터는 직렬화되고, 경계를 건너 실려 가고, 역직렬화되어야 합니다. 모든 호출은 시스템 콜과 컨텍스트 스위치의 대가를 치릅니다. 여러분의 아름다운 Rust 커널은 파이프 앞에서 기다리며 인생을 보낼 것입니다. 인프로세스에 머물러라. 모두가 아는 이야기입니다.

이 글은 모두가 안다고 여기는 것을 실제로 측정하며, 그 측정 결과는 논쟁의 어느 쪽보다도 흥미롭습니다. "IPC가 죽여버리기 때문에 더 빠른 크로스-언어 엔진도 인프로세스 numba에게 진다"는 통설은 일반적으로는 틀렸고, 특정 조건에서만 옳은 것으로 드러납니다. 경계를 한 번, raw bytes로 건너는 데 드는 비용은 2초짜리 작업에서 약 2밀리초 — 반올림 오차 수준입니다. 세금은 경계 자체에 있지 않습니다. 그것을 건너는 방식에 있습니다 — 그리고 엔진 서비스가 실전에서 배포되는 흔한 세 가지 방식(JSON API, 작업 단위당 호출, 호출마다 프로세스 스폰)은 각각, 측정 가능한 수준으로, 통설이 예측하는 재앙의 한 조각입니다.

여기 실험 전체를 먼저 제시합니다. 아래는 각 줄의 해부입니다.

아키텍처 스윕당 경계를 건너는 것 실행 시간(wall time) 인프로세스 대비
in-process numba 아무것도 없음 — 직접 호출 2.010 s 1.00x
Rust 서버, 배치(batched, Unix 소켓) 왕복 1회: 전체 시리즈 + 80개 파라미터 세트 전부 2.276 s 1.13x
Rust 서버, 배치, get_unchecked 커널 동일한 단일 왕복 — 바운드 체크 없는 커널 변형(평결 참조) 2.337 s 1.16x
Rust 서버, 수다형(chatty, Unix 소켓) 왕복 80회: 콤보마다 시리즈를 재전송 2.383 s 1.19x
Rust 스폰(spawn, stdin/stdout) 프로세스 스폰 + 파이프로 전송된 요청 1회 2.300 s 1.14x

Apple M2 Max, Python 3.14.6, numpy 2.4.3, numba 0.64.0, rustc 1.94.0(릴리스 빌드, 외부 crate 없음). 150,000개 바 × 80개 콤보, 왕복 수수료 0.09%, 시드 42; close 시리즈는 전송 시 1,200,000바이트(1.2 MB)입니다. 아키텍처당 10회 실행의 중앙값; 최소-최대 편차는 ~2% 이내로 유지됩니다. 다섯 아키텍처 모두 동일한 HMA/HMA3 스톱-앤-리버스 스윕을 실행하며, 동치성 게이트가 두 Rust 커널 변형 모두의 콤보별 (PnL, 트레이드 수) 결과가 numba와 정확히 일치함을 확인합니다 — 지문(fingerprint) PnL −5165.58, 57,029개 트레이드에 걸쳐, 동일한 시드에서 속도 사다리 연구의 numba 커널과 바이트 단위로 동일합니다. 우리는 구현이 아니라 경계를 비교하고 있습니다.

배치(batched) 행을 주의 깊게 읽으십시오. 이 논문 전체의 테제를 담고 있으니까요. 소켓을 통한 Rust 아키텍처는 인프로세스 numba보다 1.13배 느립니다 — 전체 스윕에서 266 ms 뒤처집니다(도출: 2.276 − 2.010). 통설은 그 밀리초들이 IPC라고 말합니다. 아닙니다. 그 격차 중 약 2 ms만이 경계입니다 — 1.2 MB 전체 close 시리즈를 실어 보내고, 결과를 실어 받는 것을 직접 측정한 값입니다. 나머지 ~264 ms는 단지 우리의 소박한 Rust 커널이 numba 커널보다 스윕을 약 13% 더 느리게 계산한다는 사실입니다(도출: 2.276 s에서 경계 비용 ~2 ms를 뺀 ≈ 2.274 s의 Rust 연산, numba의 2.010 s 대비). Rust라는 언어가 Python이라는 언어에게 진 것이 아닙니다. 스칼라 LLVM 컴파일 루프 하나가 또 다른 스칼라 LLVM 컴파일 루프에게 코드젠 경주에서 졌을 뿐입니다 — 그리고 우리는 그 손실을 뻔한 용의자에게 떠넘길 수조차 없었습니다: 동일 커널의 바운드 체크 없는 get_unchecked 빌드가 더 빠르지 않았기 때문입니다(2.337 s; 평결 섹션에서 이를 해부합니다). 소켓은 이 모든 것과 거의 무관했습니다.

그 문장의 두 절을 모두 붙잡고 계십시오. 경계는 올바르게 건널 때는 거의 공짜입니다 — 그리고 "Rust로 다시 쓴다"는 것은 배포 경계를 얻는 것이지, 자동으로 연산 승리를 얻는 것이 아닙니다. 두 사실 모두 대중의 직관에 반하며, 둘 다 위 표 안에 있습니다.

커널 하나, 언어 둘, 경계 넷

워크로드는 속도 사다리가 고정해 둔 것과 의도적으로 동일합니다. 두 연구가 서로 닻을 내릴 수 있도록 말이죠. 커널은 HMA/HMA3 크로스입니다 — 두 개의 Hull식 이동평균에 대한 스톱-앤-리버스 시스템으로, 파라미터 콤보마다 일곱 번의 가중이동평균 패스에 더해, 포지션을 유지하고 매 크로스마다 왕복 수수료 0.09%를 뺀 PnL을 기록하며 반전하는 상태 유지형 바 단위 이벤트 루프로 구성됩니다. 데이터는 시드가 고정된 합성 기하 브라운 운동 150,000개 바이며(seed=42), 그리드는 [6,200][6, 200] 구간에 퍼진 80개의 HMA 길이입니다. 인프로세스 기준값은 사다리의 단일 스레드 numba 단계를 이 연구를 위해 재측정한 것입니다: 그쪽에서는 1.98초, 여기서는 2.010초 — 같은 커널, 같은 머신, 안심될 만큼 지루합니다.

크로스-언어 엔진은 그 numba 커널을 Rust로 한 줄 한 줄 이식한 것입니다 — 동일한 루프, 동일한 NaN 처리, 동일한 수수료 산술 — 외부 crate 없이 릴리스 모드로 컴파일되어, 실험 전체가 의존성 없이 재현 가능한 상태를 유지합니다. 이 엔진은 의도적으로 최소한의 바이너리 프로토콜을 사용합니다: 방향마다 길이 접두사가 붙은 프레임 하나, 전부 리틀 엔디안입니다.

request:  [u32 body_len][body]
body:     [u8 opcode][u32 n_bars][u32 n_combos]
          [n_bars × f64 close][n_combos × 6 × i64 params]

opcode 0 = sweep : reply = [n_combos × f64 pnl][n_combos × i64 trades]
opcode 1 = echo  : reply = the close array, verbatim

echo opcode는 이 연구의 메스입니다: 아무것도 계산하지 않으면서 크기를 조절할 수 있는 왕복으로, 순수한 경계 비용만을 고립시켜 측정할 수 있게 해줍니다 — 직렬화, 시스템 콜, 소켓 전송, 역직렬화, 그 외에는 아무것도 없습니다.

측정된 다섯 개의 아키텍처 — 경계 패턴 네 가지에 커널 변형 하나를 더한 것입니다:

  • in_process — numba 커널을 직접 호출. 경계 없음. 기준값입니다.
  • rust_batch_unix — Unix 도메인 소켓 위의 영속적인 Rust 서버. 한 번의 왕복이 전체 close 시리즈와 80개 파라미터 세트 전부를 실어 보냅니다. Rust가 모든 콤보를 계산하고, 응답 하나가 돌아옵니다. 청크형(chunky) 호출입니다.
  • rust_batch_unchecked — 동일한 배치 경계이지만, 커널이 get_unchecked로 인덱싱합니다(핫 패스에 바운드 체크 없음). 연산 격차에 대한 특정 가설을 검증하기 위해 존재하며, 평결 섹션에서 이를 소진합니다.
  • rust_chatty_unix — 동일한 서버이지만, 콤보마다 왕복 한 번씩, 매번 1.2 MB 시리즈를 다시 실어 보냅니다. 작업 단위당 RPC라는 소박한 아키텍처입니다.
  • rust_spawn_stdin — 스윕마다 바이너리를 스폰하고 stdin으로 요청을 파이프합니다. "CLI 엔진으로 셸 아웃하는" 패턴이며, 프로세스 생성 비용을 지불합니다.

그리고 동치성 게이트 — 이것 없이는 그 무엇도 의미가 없습니다: 타이밍 측정 후, 각 Rust 변형의 콤보별 (PnL, 트레이드 수) 벡터를 numba의 것과 비교합니다 — 트레이드 수는 정확히 일치, PnL은 절대 오차 10610^{-6} 이내로 일치. 커밋된 실행은 안전 인덱싱 빌드와 get_unchecked 빌드 모두에서 all_ok: true를 보고합니다. 첫 콤보의 지문 — PnL −5165.58 퍼센트 포인트, 57,029개 트레이드에 걸쳐 — 은 속도 사다리 연구의 numba 커널과 한 자리도 틀리지 않고 일치하며, 이는 두 논문을 동일한 시드의 동일한 커널에 고정합니다. 크로스-언어 이식은 정확히 소리 없는 발산이 서식하기 좋아하는 곳입니다(퍼센트 변환 이후가 아니라 이전에 적용된 수수료, 다르게 분기하는 NaN 비교, 윈도우의 오프바이원 — 우리의 미래 참조 편향 분류가 노이즈에서 샤프 15를 만들어낼 수 있음을 보여준 것과 같은 종류의 버그입니다). 서로 다른 것을 계산하는 두 엔진의 벤치마크는 벤치마크가 아닙니다 — 서로 무관한 두 프로그램의 경주일 뿐입니다.

동치성이 확립되었으니, 위 표의 모든 차이는 경계와 연산일 뿐입니다 — 그 외에는 아무것도 아닙니다.

실제로 건너는 데 드는 비용: echo 곡선

경계를 건너는 데 드는 측정된 비용: 작은 페이로드에서는 14마이크로초에서 평평하다가, 만 개의 float를 넘어서야 위로 꺾이기 시작해, 전체 1.2메가바이트 시리즈에서 2밀리초에 도달하는 레이턴시 곡선

메스부터 시작합시다. echo op은 nn개의 float로 이루어진 페이로드를 Rust 서버를 통해 왕복시킵니다 — Python이 프레임을 만들고, 서버가 nn개의 float 전부를 파싱해 다시 인코딩한 뒤 돌려보냅니다. 양방향 모두 직렬화, 시스템 콜, 소켓 전송의 대가를 치릅니다. 다음은 측정된 곡선입니다(10회 실행의 중앙값):

페이로드(float 수) 방향당 바이트 왕복 시간
1 8 14.1 µs
100 800 16.4 µs
1,000 8,000 18.1 µs
10,000 80,000 192.5 µs
100,000 800,000 1,367.3 µs
150,000 1,200,000 2,043.4 µs

이 표 안에는 두 가지 구조적 사실이 살고 있습니다.

첫째, 바닥(floor). 사실상 아무것도 싣지 않은 왕복 — 8바이트 — 은 14 µs가 듭니다. 이것은 이 전송 방식으로 호출을 하는 행위 자체의 더 이상 줄일 수 없는 가격입니다: write 시스템 콜 두 번, read 시스템 콜 두 번, 커널 소켓 기계, 스케줄러 웨이크업. 곡선의 왼쪽이 얼마나 평평한지 주목하십시오: float 1개에서 1,000개까지 비용이 거의 움직이지 않습니다(14.1 → 18.1 µs). 약 8 KB 아래에서는 바이트가 아니라 호출 자체에 대한 비용을 치르고 있는 것입니다. 이 숫자 — 레이턴시 바닥 — 는 이 연구 전체에서 가장 중요한 단 하나의 상수이며, 아래에서 이를 바탕으로 손익분기 산술을 구축할 것입니다.

둘째, 기울기. 약 10,000개의 float를 넘어서면 곡선은 대역폭에 종속되어 대략 선형이 됩니다. 전체 1.2 MB 시리즈 — 왕복으로 총 2.4 MB가 이동하며, Rust 쪽에서 150,000개 float 전체를 파싱하고 재인코딩하는 것을 포함해서 — 는 2,043.4 µs가 듭니다. 이는 이 소박한 스택 전체를 통틀어 실효 ~1.2 GB/s에 해당합니다(도출: 2.4 MB / 2.04 ms) — 길이 접두사 프레임과 바이트 단위 float 파서를 쓰는 Unix 도메인 소켓으로, 제로카피 트릭도, 공유 메모리도, 영리한 그 무엇도 없이 말입니다.

측정된 두 상수로 만든, 건너기 한 번에 대한 합리적인 모델은 다음과 같습니다:

Tcall(b)    14 μsfloor  +  2b1.2 GB/spayload, both waysT_{\text{call}}(b) \;\approx\; \underbrace{14\ \mu\text{s}}_{\text{floor}} \;+\; \underbrace{\frac{2b}{1.2\ \text{GB/s}}}_{\text{payload, both ways}}

이제 헤드라인 숫자를 맥락 속에 놓아 봅시다. 전체 스윕은 인프로세스에서 2.010초가 걸립니다. 전체 데이터셋을 경계 너머로 왕복시키는 데 드는 비용은 ~2.0 ms — **작업의 약 0.1%**입니다(도출: 2.0434 ms / 2.010 s). 한 번, raw bytes로 건넌다면 경계는 반올림 오차입니다. 이것이 통설 중 가장 먼저 죽는 절반입니다: 애초에 이렇게 저렴한 것에 대한 두려움은 없었습니다.

그 건너기의 Rust 쪽 코드는 시스템 코드가 될 수 있는 한 가장 화려하지 않은 모습입니다 — engine/src/main.rs에서 가져와 수정했습니다:

fn read_frame<R: Read>(r: &mut R) -> Option<Vec<u8>> {
    let mut len_buf = [0u8; 4];
    r.read_exact(&mut len_buf).ok()?;
    let len = u32::from_le_bytes(len_buf) as usize;
    let mut body = vec![0u8; len];
    r.read_exact(&mut body).ok()?;
    Some(body)
}

fn write_frame<W: Write>(w: &mut W, body: &[u8]) {
    w.write_all(&(body.len() as u32).to_le_bytes()).unwrap();
    w.write_all(body).unwrap();
    w.flush().unwrap();
}

// the server is a loop: read frame -> compute -> write frame
for stream in listener.incoming() {
    serve_stream(stream.unwrap());
}

다음으로 넘어가기 전에 정직한 범위 언급 하나: 이 연구의 모든 경계 수치는 한 호스트 위의 Unix 도메인 소켓입니다. 이 엔진은 TCP도(TCP_NODELAY와 함께) 지원하지만 측정하지 않았습니다. 루프백 TCP는 이 바닥값들보다 다소 위에 위치하며, 실제 네트워크 홉은 완전히 다른 영역입니다 — 마이크로초가 아니라 밀리초 단위의 바닥입니다. 따라서 여기 있는 모든 것은 이런 방식으로 경계를 건너는 데 있어 거의 최선의 경우입니다. 그 때문에 다음에 측정할 세금들은 더더욱 유죄입니다: 그것들은 이 위에 추가로 치르는, 선택에 의한 비용이기 때문입니다.

직렬화 세금: JSON을 선택하는 대가, 1348배

동일한 150,000개 float 배열의 두 가지 인코딩을 나란히: 마이크로초 단위로 측정된 raw-bytes memcpy와, 그보다 세 자릿수나 더 높이 솟은 JSON 텍스트 인코딩

"IPC 오버헤드"에 대한 통설이 잘못된 라벨이었음이 드러나는 지점이 여기입니다. 우리는 동일한 150,000개 float짜리 close 시리즈를 인코딩하는 비용을 세 가지 방식으로 측정했습니다 — 위의 모든 아키텍처가 실어 보내는 바로 그 페이로드입니다:

인코딩 1.2 MB의 float를 인코딩하는 시간 raw 대비
raw bytes (.tobytes()) 49.1 µs 1.0x
pickle 29.8 µs 0.6x
JSON (json.dumps(close.tolist())) 66,243 µs 1348x

raw 경로는 함수 호출의 탈을 쓴 memcpy입니다:

def build_request(opcode, close, params):
    body = bytes([opcode]) + struct.pack("<II", len(close), len(params))
    body += close.astype("<f8").tobytes()      # 150,000 floats -> 1.2 MB in 49 µs
    body += np.asarray(params, dtype="<i8").reshape(-1).tobytes()
    return struct.pack("<I", len(body)) + body  # length-prefixed frame

(Pickle이 우리의 raw 경로보다 오히려 살짝 더 저렴하게 나온 이유는, dtype이 이미 일치하더라도 astype이 dtype 변환 복사 비용을 치르기 때문입니다. 둘 다 memcpy급이며 둘 다 반올림 오차입니다. 바이너리 계열 전체가 텍스트 계열보다 세 자릿수 아래에 살고 있습니다.)

그리고 텍스트 경로는 "엔진을 마이크로서비스로 만들자"는 배포가 실제로 거의 항상 실어 보내는 것입니다:

body = json.dumps({"op": "sweep", "close": close.tolist(), "params": params})

66밀리초. 인코딩하는 데만요. json.dumps(close.tolist())는 모든 float를 Python 객체로 박싱한 뒤, 각각을 십진 텍스트로 렌더링합니다 — raw 경로가 블록 복사 한 번으로 끝낸 일에 150,000번의 힙 할당과 150,000번의 float-투-문자열 변환이 들어갑니다. 그리고 전송 페이로드도 부풀어 오릅니다(float64는 바이너리로 8바이트지만 십진 텍스트로는 대략 그 두세 배입니다 — 우리는 이 추가 전송 비용조차 청구하지 않았습니다).

이제 실제 배포가 하는 방식으로 규모를 키워 봅시다. 그 66 ms는 인코딩 한 번, 한쪽, 호출 한 번짜리입니다. JSON 서비스는 인코딩 디코딩을, 경계의 양쪽 모두에서, 호출마다 치릅니다. 배치된 호출 하나만 JSON으로 보내도 클라이언트 측 인코딩만으로 전체 스윕 연산 예산의 ~3.3%를 태웁니다(도출: 66 ms / 2.010 s). JSON을 수다형 아키텍처 아래에 두면 — 콤보당 호출 하나씩, 아래의 패턴대로 — 클라이언트 측 인코딩만으로 80 × 66 ms = 5.3초가 듭니다: 단 1바이트도 움직이기 전에, 서버가 무엇 하나 파싱하기도 전에, 전체 유용한 작업의 두 배 반이 넘는 시간입니다(도출).

이것이야말로 대부분의 팀이 모르는 채로 프로덕션에서 측정해 온 진짜 "IPC 세금"입니다. 그것은 프로세스 간 통신이었던 적이 결코 없습니다. 숫자 배열의 텍스트 직렬화였습니다 — 경계에서 가장 저렴해야 할 구성 요소에 스스로 매긴 1348배의 세금입니다. 컬럼형(columnar) 진영은 이 교훈을 수년 전에 배웠고, 이는 우리의 Polars vs pandas 연구가 데이터 파이프라인 쪽에서 계속 부딪혔던 것과 동일한 교훈입니다: Arrow 같은 포맷이 존재하는 이유는 정확히 배열 데이터가 텍스트가 아니라 raw 컬럼형 바이트로 프로세스와 언어 경계를 건널 수 있게 하기 위해서입니다. 여러분의 엔진 서비스가 가격 배열에 대해 JSON을 사용한다면, 어떤 소켓 튜닝도 여러분을 구하지 못합니다 — 프로토콜 자체가 병목입니다.

수다형 대 청크형: Fowler의 법칙을 측정하다

하나의 큰 프레임 페이로드를 경계 너머로 단 한 번 실어 보내는 청크형(chunky) 아키텍처와, 매번 전체 데이터셋을 끌고 가는 여든 번의 작은 왕복을 하는 수다형(chatty) 아키텍처를 나란히

Martin Fowler의 분산 객체 설계 제1법칙 — "객체를 분산시키지 말라" — 에는 그가 같은 호흡으로 명시한 따름정리가 딸려 있습니다: 경계를 반드시 건너야 한다면, 인터페이스는 **거친 입자(coarse-grained)**여야 한다는 것입니다. 원격 호출은 로컬 호출보다 몇 자릿수나 더 비싸기 때문입니다. 모든 분산 시스템 베테랑이 고개를 끄덕입니다. 그러나 자기 자신의 워크로드에 대한 숫자를 가진 사람은 거의 없습니다. 여기 우리의 숫자가 있습니다.

청크형과 수다형 아키텍처는 같은 서버, 같은 프로토콜, 같은 데이터로 실행됩니다 — 오직 호출 입자도만 다릅니다:

srv.call(0, close, params)

[srv.call(0, close, [params[k]]) for k in range(n)]

청크형: 2.276초(1.13배). 수다형: 2.383초(1.19배) — 107 ms 더 느립니다(도출: 2.383 − 2.276). 이 델타가 무엇이고 무엇이 아닌지 정확히 짚자면: echo 곡선은 이에 대한 소박한 예측치를 제공합니다 — 전체 시리즈를 79번 추가로 실어 보내되 각각 2,043 µs 전체 페이로드 왕복의 대략 절반 정도씩, 약 81 ms — 이는 측정된 107 ms보다 약 25% 낮게 나옵니다. 나머지는 Python 쪽의 호출별 요청 구성과 프레이밍이며, echo 예측에는 포함되지 않은 부분입니다. 어느 쪽이든 추가 건너기 1회당 ~1.4 ms에 해당합니다(도출: 107 / 79). 응답 쪽은 무시할 만합니다 — 콤보당 16바이트.

그 107 ms에는 두 가지 해석이 있고, 둘 다 중요합니다.

관대한 해석: 전체 시간의 ~4.5%에 불과하며, 재앙이 아닙니다. 사실입니다 — 그리고 통설이 예언한 재앙이 왜 여기서는 실현되지 않았는지 이해할 가치가 있습니다. 수다형 호출도 여전히 25,130 µs의 실제 연산을 실어 나릅니다(콤보 하나 분량 — 측정된 인프로세스 콤보당 비용입니다). 그래서 호출당 ~1.4 ms의 경계 오버헤드는 호출당 작업량보다 한 자릿수 아래에 머뭅니다. 수다형 아키텍처는 각 호출이 진짜로 무거울 때는 치명적이지 않습니다. 입자도가 작아질수록 치명적이 됩니다 — 이것이 손익분기 섹션 전체의 주제입니다.

유죄를 선고하는 해석: 이 세금은 전적으로 자발적이었으며, 호출 횟수 × 페이로드에 비례해 커집니다. 수다형 패턴이 매 호출마다 데이터셋을 다시 실어 보내는 이유는 단 하나입니다: 서비스가 *상태 비저장(stateless)*이라, 모든 요청이 모든 맥락을 실어 날라야 하기 때문입니다. 그것이 소박한 "스윕 엔드포인트"의 기본 형태입니다 — 그리고 화이트보드 위에서 스케치된 거의 모든 REST 마이크로서비스의 기본 형태이기도 합니다. 상태 유지형(stateful) 서버라면 — 시리즈를 한 번만 로드하고, 그다음 48바이트짜리 파라미터 프레임만 보내는 — 콤보당 호출을 echo 곡선의 작은 페이로드 끝단 근처로 옮길 것입니다: 호출당 약 16 µs, 80개 전부 합쳐 대략 1.3 ms(echo 바닥에서 도출; 별도로 측정하지 않은 해석적 값입니다). 수다형의 페널티는 줄어드는 게 아니라 사라질 것입니다. 교훈은 정확합니다: 문제는 호출을 많이 하는 것이 아니라 — 프로토콜이 모든 호출을 처음인 척하기 때문에 상태를 다시 실어 보내는 것입니다.

데이터를 미리 로드하십시오. 파라미터만 실어 보내십시오. 매번 온 세상을 여행 가방에 담아가지 말고, 의도를 갖고 경계를 건너십시오.

스폰 비용: 호출 단위로 엔진을 빌리다

단일 요청을 위해 처음부터 스폰되는 엔진 바이너리: 짧은 유용한 작업 구간 앞에 고정된 톨게이트처럼 쌓인 프로세스 생성, 로더, 파이프 설정

세 번째 배포 패턴이 가장 오래됐습니다: 서버가 아예 없습니다. 엔진 바이너리를 스폰하고, stdin으로 요청 하나를 파이프하고, stdout에서 응답을 읽고, 죽게 놔둡니다. 모든 셸 스크립터의 본능, "Python에서 그냥 CLI를 호출하자"는 모든 통합, 시도(trial)마다 바이너리를 하나씩 실행하도록 구성된 모든 하이퍼파라미터 프레임워크가 이 패턴을 씁니다.

측정값: 2.300초(1.14배) — 영속적인 서버 배치보다 약 24 ms 더 걸립니다(도출: 2.300 − 2.276). 그 24밀리초는 fork/exec, 동적 로더, 파이프 설정, 프로세스 정리를 사줍니다. 그리고 이 측정치가 이 패턴의 바닥에 가깝다는 점에 주목하십시오: 페이지 캐시에 예열된, 의존성 없는 작은 네이티브 바이너리입니다. 런타임이 딸린 무언가를 스폰하는 것 — JVM, import가 있는 Python 인터프리터 — 은 훨씬 더 비쌉니다. 여기서는 측정하지 않았지만, 방향에는 의심의 여지가 없습니다.

이 세금의 구조가 중요합니다: 호출당 고정이며, 호출이 얼마나 많은 작업을 나르는지와 무관합니다. 80콤보 전체 스윕에 걸쳐 상각하면 24 ms는 약 1% — 노이즈입니다. 콤보마다 재스폰하면 동일한 상수가 80 × ~24 ms ≈ 1.9초가 됩니다 — 사실상 유용한 작업 전체가 프로세스 생성에 타버리는 셈입니다(도출; 해석적 값). 마다 재스폰한다면 그 산술은 적어볼 가치조차 없습니다.

고정 비용이냐, 미세한 입자도냐: 둘 중 하나를 고르십시오. 스폰 비용을 치르는 패턴이 온전한 선택이 되는 것은 스폰이 드물고 그 뒤에 있는 페이로드가 거대할 때뿐입니다 — 정확히 우리의 스윕당 스폰 1회 측정과 같고, 심볼 수가 늘어나면 결국 사용되는 방식이 되어버리는 심볼당 서브프로세스 아키텍처와는 정확히 다릅니다.

손익분기 산술: 바닥은 곧 허들 레이트다

저울 위의 손익분기 산술: 한쪽에는 14마이크로초의 경계 바닥, 다른 쪽에는 각 호출이 나르는 연산량 — 콤보당 호출은 수면 위로 한참 떠 있고, 바당 호출은 익사한 모습

지금까지 측정한 모든 것은 하나의 설계 규칙으로 압축됩니다. 그리고 그 규칙은 의견이 아니라 산술입니다.

모든 경계 건너기는 최소한 레이턴시 바닥만큼의 비용을 치릅니다 — 여기서는 14 µs, 작은 페이로드 echo 왕복이며, 이 전송 방식이 제공하는 최선에 가깝습니다. 그 바닥은 허들 레이트입니다: 경계 너머로 호출을 만들 가치가 있는 것은, 그것이 실어 나르는 연산이 허들을 넉넉한 배수로 넘어설 때뿐입니다. 입자도 비율을 다음과 같이 정의합니다

G  =  Tcompute per callTfloorG \;=\; \frac{T_{\text{compute per call}}}{T_{\text{floor}}}

그러면 경계가 차지하는 실행 시간의 몫은 대략 1/(1+G)1/(1+G)입니다 — 호출이 데이터도 나른다면 그 위에 페이로드 전송 비용이 추가됩니다.

이제 스윕의 숫자를 여기에 대입해 봅시다. 콤보 하나의 측정된 인프로세스 비용은 25,130 µs입니다. 콤보당 입자도에서:

G  =  25,130 μs14 μs    1795G \;=\; \frac{25{,}130\ \mu\text{s}}{14\ \mu\text{s}} \;\approx\; 1795

콤보당 호출은 바닥보다 ~1,795배 위에 있습니다 — 경계가 호출당 차지하는 몫은 0.1%에 한참 못 미칩니다. 이것이 바로 수다형 아키텍처조차 겨우 107 ms만 잃은 이유입니다: 이 워크로드의 입자도에서는, 데이터를 재전송하거나 텍스트를 쓰지 않는 한 어떤 건너기 패턴이든 안전하게 상각됩니다. 콤보 단위, 폴드 단위, 스윕 단위 호출은 모두 저렴한 영역 깊숙이 있습니다.

이제 정반대 극단으로 뒤집어 봅시다. 이것은 예시용 워크로드-간 외삽입니다 — 우리 스윕의 변형이 아니라, 실전에 진짜로 존재하는 워크로드 형태입니다: 엔진이 바(bar)마다 호출되는 경우입니다. 라이브 스타일의 틱당 엔진 서비스, bar당 gRPC 시그널 스트림, 150,000개 바 하나하나마다 한 번씩 폴링되는 "전략 서버". 이 커널에서 바당 유용한 연산은 25,130 µs / 150,000 ≈ 0.17 µs입니다(도출) — 각 호출은 자신이 나르는 유용한 작업의 약 1/84에 해당하는 경계 비용을 지불하는 셈입니다(도출: 0.168 µs의 연산 대비 14.05 µs 바닥). 총합은 그 비율이 들리는 것보다 더 나쁩니다:

150,000 calls×14 μs    2.1 s of pure IPC150{,}000 \ \text{calls} \times 14\ \mu\text{s} \;\approx\; \mathbf{2.1\ s\ of\ pure\ IPC}

전체 2.010초짜리 인프로세스 작업보다도 많은 시간이, 원격 엔진이 숫자 하나를 계산하기도 전에 소모됩니다. 그리고 이 2.1초는 반대편 엔진이 무한히 빠르더라도 그대로 남습니다(도출: 150,000 × 14 µs). 이렇게 미세한 입자도에서는 그 어떤 연산상의 우위도 살아남지 못합니다. 그리고 이 바닥값이 한 호스트 위의 Unix 소켓이라는 것을 기억하십시오. 그 바당 호출을 네트워크 너머의 서비스로 보낸다면, 바닥은 150,000번의 호출에 걸쳐 두세 자릿수만큼 더 커집니다.

구현 선택으로서의 동일 머신 경계 바닥: 14마이크로초짜리 Python-오버-Unix-소켓 왕복이, 39나노초짜리 공유 메모리 링 건너기 위로 세 자릿수만큼 우뚝 솟아 있는 모습

정직한 보정 하나를 더 하자면, 14 µs 역시 물리 법칙이 아니기 때문입니다 — 그것은 우리 전송 방식의 가격입니다: Python 클라이언트, 커널 소켓, 양방향 시스템 콜. 동일 머신 전용으로 만들어진 전송 방식은 훨씬 더 낮게 내려갑니다. ZigBolt — HFT 워크로드를 위한 우리의 오픈소스 Zig 메시징 버스로, 바로 이 머신 위에서 네이티브로 벤치마크했습니다 — 는 공유 메모리 링 왕복을 평균 약 39 ns만에 해냅니다(64/256/1024바이트 메시지에서 단방향 p50 10/20/30 ns). 이는 우리 소켓 바닥보다 대략 360배 낮은 수치입니다(도출: 14.05 µs / 39 ns). 이 비교는 의도적으로 사과와 오렌지를 비교하는 것이며, 우리도 그렇게 표시합니다: 우리의 14 µs는 Python 클라이언트의 소켓 왕복이고, ZigBolt의 39 ns는 공유 메모리 위의 네이티브 Zig이므로, 그 격차는 전송 방식 런타임을 뒤섞고 있습니다. 이것을 둘 사이의 경주가 아니라 동일 머신 바닥이 차지할 수 있는 범위: 구현에 따라 약 세 자릿수로 읽으십시오. 이것은 현대적 옷을 입은 오래된 경량 RPC의 교훈입니다(Bershad et al., 1990) — 동일 머신 건너기는 프로토콜 기계에 의해 지배되며, 전송 방식이 동일 머신 케이스를 위해 만들어지면 그 비용은 무너져 내립니다. 위의 손익분기 산술은 형태가 바뀌지 않습니다. 허들만 옮겨갈 뿐입니다. 39 ns 바닥에서는 바당 입자도조차 허들을 넘어설 것입니다(150,000 × 39 ns ≈ 5.9 ms, 도출) — 이것이 정확히 HFT 시스템이 REST 서비스는 감당할 수 없는 경계를 감당할 수 있는 이유입니다.

손익분기 이야기 전체를 한 문장으로: 경계는 여러분의 엔진이 얼마나 빠른지 신경 쓰지 않습니다. 건널 때마다 요금을 매길 뿐이며, 여러분이 통제할 수 있는 변수는 각 건너기가 얼마만큼의 작업을 나르는가 — 그리고 그 건너기가 무엇으로 만들어졌는가입니다. 스윕당 배치하면 GG는 십만 대를 넘습니다. 콤보당 배치하면 G1795G \approx 1795 — 여전히 괜찮습니다. 소켓으로 바당 호출하면 G<1G < 1 — 이 아키텍처는 첫 최적화도 해보기 전에 죽어 있으며, Rust든 그 무엇이든 엔진을 다시 쓴다고 해서 되살릴 수 없습니다.

1.13배는 실제로 어디에 있는가 — 그리고 평결

해부된 266밀리초의 격차: 경계라고 표시된 2밀리초짜리 얇은 조각 옆에, 두 개의 스칼라 컴파일 커널 사이에서 측정된 코드젠 차이라는 커다란 덩어리가 놓여 있고, 통설에는 줄이 그어져 있는 모습

이제 헤드라인 격차를 정직하게 해부할 시간입니다. 이 연구에서 가장 반직관적인 발견을 담고 있으니까요.

배치된 Rust 아키텍처는 인프로세스 numba보다 266 ms 뒤처집니다(도출: 2.276 − 2.010). 측정된 경계 구성 요소들: 전체 페이로드 왕복 1회 ~2.0 ms, raw 직렬화 49 µs, 프레임 헤더 몇 바이트 — 경계 전체 청구서를 ~2 ms라고 부릅시다. 따라서 격차의 99% 이상은 경계와 전혀 무관합니다. 그것은 연산입니다: IPC를 걷어내면, Rust 서버는 numba가 2.010초 만에 해내는 스윕을 하는 데 ~2.274초를 씁니다 — 소박한 Rust 커널은 순수 연산에서 약 13% 더 느립니다(도출).

이 대목은 회피하지 않는 문단을 요구합니다. "Rust로 다시 쓰면 더 빨라질 것이다"는 "IPC가 죽여버릴 것이다"만큼이나 통설이기 때문입니다. 두 커널 모두 LLVM에서 바닥을 칩니다 — numba는 Python 바이트코드를 LLVM으로 낮추고, rustc는 MIR을 LLVM으로 낮춥니다 — 그리고 둘 다 십중팔구 스칼라 루프로 돌아갑니다: WMA의 내부 합산은 부동소수점 리덕션이며, numba의 @njit 기본값이 부여하지 않고 우리의 이식도 요청하지 않는 fast-math 재결합 라이선스 없이는 LLVM이 자동 벡터화하지 않습니다. 그러니 ~13%는 두 스칼라 LLVM 컴파일 루프 사이의 측정된 코드젠 격차입니다 — 그리고 원인을 단정하는 대신, 우리는 가장 뻔한 것을 검증했습니다. 자연스러운 용의자는 Rust의 안전한 인덱싱입니다: 핫 WMA 루프는 배열 접근마다 바운드 체크를 하는데, numba의 @njit는 바운드 체크를 끈 채로 컴파일합니다. 그래서 우리는 동일 커널의 get_unchecked 변형 — 핫 패스 어디에도 바운드 체크가 없는 — 을 동치성까지 검증해서 만들고, 다섯 번째 아키텍처로 시간을 쟀습니다. 격차는 좁혀지지 않았습니다: **2.337초(1.16배)**로, 바운드 체크가 있는 빌드의 2.276초보다 오히려 미세하게 더 느렸습니다. 가설을 검증했고, 가설은 기각되었습니다. 정직한 지식의 현재 상태: ~13%는 실재하며 재현 가능하지만(10회 실행의 중앙값, 편차 ~2% 이내), 현재로서는 원인 불명입니다 — 어셈블리 수준 프로파일링만이 밝혀낼 수 있는 할당 동작, 루프 구조, 또는 명령어 스케줄링의 어떤 차이일 것입니다. 교훈은 온전히 살아남습니다: 소박한 Rust는 좋은 numba보다 자동으로 빠르지 않으며, 공짜 연산 승리를 가정하고 구매한 언어 경계는 연산 손실을 딸려 가져올 수 있습니다. 튜닝된 Rust 커널이라면 — 사전 할당된 버퍼, 명시적 SIMD, 콤보에 걸친 스레드 — 그래도 부호를 뒤집을 수 있을 것입니다. 하지만 그것은 프로파일링과 커널 작업으로 해결할 연산 문제이며, 이 연구의 질문은 경계입니다. 경계의 답: 한 번, bytes로 건너면, 비용은 ~0.1%입니다.

그러니 전체 평결을 조립해 봅시다. 그 모든 절은 위에서 측정된 것들입니다.

크로스-언어 엔진 서비스가 승리하는 경우는 다음 전부가 성립할 때입니다:

  • 연산상의 우위가 실재한다 — 언어의 명성으로 가정한 것이 아니라 여러분의 커널에서 측정된 것. (우리 것은 다른 것이 증명될 때까지 −13%였습니다 — 그리고 그 결손에 대한 첫 "뻔한" 설명은 검증 과정에서 죽었습니다.)
  • 거칠게 건넌다 — 스윕당 또는 폴드당 호출 하나, 14 µs 바닥보다 수천 배 위. 배치 아키텍처의 전체 1.13배(~0.1% 경계)가 보여주는 대로입니다.
  • 바이너리를 사용한다 — 길이 접두사가 붙은 raw 배열, Arrow, 1.2 MB당 49 µs의 memcpy급이라면 무엇이든; 66,243 µs짜리 텍스트는 절대 안 됩니다.
  • 데이터가 미리 로드되어 있다 — 상태 유지형 서버는 메가바이트를 재전송하는 대신 echo 곡선의 ~16 µs 끝단에서 파라미터만 받는 호출을 처리합니다.

엔진 서비스가 보통 배포되는 방식으로 배포되면 패배합니다:

  • JSON/REST 마이크로서비스 — 매 호출마다, 양방향으로 1348배의 직렬화 세금을 냅니다. 수다형 입자도에서는 2초짜리 작업에 5.3초의 인코딩입니다.
  • 작업 단위당 RPC — 콤보당이면 여기서는 107 ms가 들며, 각 호출이 25,130 µs의 연산을 나르기 때문에야 겨우 살아남습니다. 바당이면 어떤 작업이 일어나기도 전에 ~2.1초의 순수 IPC를, 2.0초짜리 작업에서 치릅니다.
  • 호출당 스폰 — 매번 ~24 ms의 고정 비용이며, 스윕당 한 번이면 무해하지만 콤보당 치르면 거의 2초에 달합니다.

다시 말해: 실패하는 아키텍처들은 이국적인 것이 아닙니다. JSON REST 엔진, 심볼당 서브프로세스, 틱당 gRPC — 이것이 "백테스트 엔진을 분리해내자"가 실제로 만들어지는 방식에 대한 공정한 인구조사입니다. 통설은 흔한 관행에 대한 서술로서는 경험적으로 잘 뒷받침되고, 자연 법칙으로서는 경험적으로 틀렸습니다. 경계는 결코 문제였던 적이 없습니다. 그것을 건너는 기본 방식들이 문제였습니다.

경계를 옹호하는 논거 하나는 따로 문장을 받을 자격이 있습니다. 애초에 우리가 이 연구를 수행한 이유이기 때문입니다. 잘 설계된 경계 뒤에 있는 컴파일된 커널 하나는 리서치 스윕과 라이브 트레이딩 루프 모두를 섬길 수 있습니다 — 같은 바이너리, 같은 산술, 비트 단위로 동일하게. 우리의 백테스트-라이브 일치성 연구는 리서치 엔진과 프로덕션 엔진이 두 개의 코드베이스일 때 어떻게 서로 어긋나는지 목록화했습니다. 엔진 서비스는 그 어긋남에 대한 가장 강력한 구조적 처방이며, 이 연구는 그 처방의 가격을 정직하게 매깁니다: 제대로 하면, 실행 시간의 약 0.1%와, 번역 과정에서 아무것도 바뀌지 않았음을 증명하는 동치성 게이트입니다. 그 거래 — 전용 프로세스 경계를 커널 하나의 일치성과 맞바꾸는 것 — 는 이 숫자들 위에서는 헐값입니다. 잘못하면, 같은 아이디어가 여러분의 PnL을 태운 채 1348배의 직렬화 세금을 프로덕션으로 실어 보냅니다.

핵심 요점

  1. 경계는 거의 공짜입니다. 통설은 측정 앞에서 무너집니다. 전체 1.2 MB의 close 시리즈를 Unix 소켓으로 왕복시키는 데 — 전체 파싱과 재인코딩 포함 — 2,043.4 µs가 듭니다. 2.010초짜리 작업의 약 0.1%입니다(도출). 배치된 Rust-오버-소켓 아키텍처는 전체 1.13배에 안착하며, 그 격차조차 ~99%가 IPC가 아닙니다.
  2. "Rust로 다시 쓴다"는 연산에 대한 주장입니다 — 경계를 사기 전에 검증하십시오. 우리의 한 줄 한 줄 Rust 이식은 numba 커널보다 ~13% 더 느리게 연산합니다(도출: 2.274 s vs 2.010 s) — 원인이 규명되지 않은, 두 스칼라 LLVM 컴파일 루프 사이의 재현 가능한 코드젠 격차입니다: 우리는 가장 뻔한 용의자를 검증하고 기각했습니다. 바운드 체크가 없는, 동치성이 검증된 get_unchecked 빌드가 더 빠르지 않았기 때문입니다(2.337 s vs 2.276 s). 소박한 Rust는 자동으로 빠르지 않습니다. 튜닝된 커널이라면 그럴 수도 있습니다 — 측정한 다음 결정하십시오.
  3. 진짜 세금은 텍스트입니다. 150,000개 float를 JSON으로 인코딩하는 비용은 raw의 49.1 µs 대비 66,243 µs — 1348배이며, 방향마다, 호출마다, 양쪽 모두에서 치릅니다. 수다형 JSON 배포는 2초짜리 작업에서 5.3초의 인코딩을 태웁니다(도출). 경계 너머로는 바이너리를 쓰십시오: raw 프레임, Arrow — 가격 배열에는 json.dumps를 절대 쓰지 마십시오.
  4. 수다형과 청크형은 측정 가능하며, 범인은 상태 비저장성입니다. 데이터를 재전송하는 콤보당 호출: 배치의 1.13배 대비 1.19배(+107 ms, 도출; echo 곡선의 단방향 예측치 ~81 ms는 이보다 ~25% 낮게 나오며, 나머지는 호출별 프레이밍입니다). 미리 로드된 상태 유지형 서버라면 같은 80번의 호출을 각 ~16 µs에 처리했을 것입니다 — 합계 약 1.3 ms(echo 바닥에서 도출). 데이터셋이 아니라 파라미터를 실어 보내십시오.
  5. 바닥을 존중하십시오 — 그리고 바닥은 선택이라는 것을 아십시오. 우리의 Python-오버-Unix-소켓 건너기는 14 µs에서 바닥을 칩니다. 콤보당 입자도는 이를 ~1,795배 넘어섭니다(호출당 연산 25,130 µs) — 안전합니다. 바당 패턴(이 스윕의 변형이 아니라, 예시용 워크로드-간 극단값입니다: 틱당 라이브 엔진)이라면 2.0초짜리 작업에서 순수 IPC에 150,000 × 14 µs ≈ 2.1초를 치릅니다(도출) — 엔진이 무한히 빠르더라도 도착 즉시 사망입니다. 호출마다 스폰하면 고정 ~24 ms가 추가됩니다(도출). 그리고 ZigBolt 같은 전용 공유 메모리 전송 방식은 이 머신 위에서 네이티브로 ~39 ns만에 왕복합니다 — 우리 소켓 바닥보다 ~360배 낮습니다(도출; 네이티브 Zig 대 Python 클라이언트이므로, 경주가 아니라 바닥이 차지할 수 있는 범위로 읽으십시오).
  6. 한 번, bytes로, 데이터가 이미 그 자리에 있는 채로 건너십시오 — 그러면 경계는 ~0.1%의 비용으로 일치성을 사줍니다. 리서치와 라이브 모두를 섬기는 커널 하나를, 동치성 검사(PnL −5165.58, 57,029개 트레이드, 언어 간에도 두 Rust 빌드 간에도 동일)로 검문하는 것 — 이것이 엔진 서비스를 위한 정직한 사례입니다. 부정직한 사례들 — JSON, 수다형, 호출당 스폰 — 이 바로 IPC에게 그 오명을 안겨준 것들입니다.

전체 실험 — Rust 엔진, 와이어 프로토콜, echo와 직렬화 하니스, 동치성 게이트, 그리고 이 글의 모든 숫자를 하나의 결정론적 스크립트로 재생성할 수 있는 것까지 — 은 ipc-tax.marketmaker.cc의 동반 논문에 있으며, 코드와 데이터는 github.com/suenot/ipc-tax에 있습니다.

소켓은 결코 문제였던 적이 없습니다. 전체 데이터셋에 왕복 2밀리초 — 통설은 세 자릿수만큼 틀렸고, 그것도 양방향으로 동시에 틀렸습니다: bytes에는 지나치게 비관적이었고, 텍스트에는 지나치게 관대했습니다. 경계를 마치 무언가 비용이 드는 것처럼 건너십시오. 그러면 정말로 비용이 들지 않을 것입니다.

blog.disclaimer

Authors

Eugen Soloviov
Eugen Soloviov

Trading-systems engineer

Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.

Newsletter

시장에서 앞서 나가세요

뉴스레터를 구독하여 독점적인 AI 트레이딩 통찰력, 시장 분석 및 플랫폼 업데이트를 받아보세요.

귀하의 개인정보를 존중합니다. 언제든지 구독을 취소할 수 있습니다.