Skip to content

벤치마크 ​

모든 측정값이에요. 10배에 못 미친 항목(⛔)과 그 이유, 시도했다가 넣지 않은 것까지 그대로 적어요. README에는 이 중 한 가지 규칙으로 범위 전체가 10배 이상인 항목만 올려요.

요약: 전체 항목 ​

측정한 15개 항목 중 11개는 범위 전체가 tmux보다 10배 이상 좋아요. 나머지 4개(⛔)는 범위의 아래 끝이 1.6~8.9배예요. 하한의 원인은 아래 10배 목표 현황에 적었어요. 모든 값은 같은 기기(Mac mini M1)에서 tmux와 번갈아 잰 실측이에요.

11개 중 하나는 tmux가 느려져서 10배를 넘었어요. 5.4MB 출력이 화면에 보이기까지는 2026-10-18에 경계(9.9~11.7×)였고 2026-10-21에는 10.5~12.5×예요. zeropty는 두 날 모두 같았고(14~16ms, 그다음 15~16ms), tmux가 더 바쁜 기기에서 159~164ms에서 169~188ms가 됐어요. 규칙상 ✅이지만 README에는 올리지 않아요.

무엇을tmuxzeropty배수줄어든 비율
CPU, 실제 claude 작업 400개54.30~54.91%3.51~3.84%14.1~15.6×−92~93%
메모리, 실제 claude 작업 400개 (독립 서버)32.4~32.5MB2.66~2.70MB12.0~12.2×−91%
메모리, 실제 claude 작업 400개 (임베디드, polled)32.4~32.5MB1.63~1.64MB19.7~19.9×−94%
메모리, 대기 400개 (임베디드, polled)8.0MB0.45~0.46MB17.3~17.7×−94%
메모리, 대기 200개 (임베디드 + 서버 플러그인, CLI·attach로도 접근)5.0~5.1MB388~389KB12.8~13.1×−92%
메모리, 화면 갱신 400개251.2~251.4MB1.71~1.74MB144~147×−99%
메모리, 대기 400개 (독립 서버)8.0MB1.25~1.27MB6.2~6.4× ⛔−84%
send-keys, tmux 명령 실행 vs zeropty 소켓 (Node)9.03~9.56ms0.24~0.26ms34.7~39.8×−97%
send-keys, tmux 제어 모드(-C) vs zeropty 임베디드 (Node)0.42~0.49ms0.06~0.07ms6.0~8.1× ⛔ (회차별 7~8× †)−83~87%
send-keys, tmux 제어 모드(-C) vs zeropty 소켓 (Node)0.42~0.49ms0.24~0.26ms1.6~2.0× ⛔ (회차별 1.7~2.0× †)−38~51%
키 입력 → 화면, tmux 제어 모드(-C) vs zeropty 임베디드 (Node)0.89~0.98ms0.09~0.10ms8.9~10.8× ⛔ (회차별 9~10× †)−88~90%
셸에서 has-session, tmux vs zeropty zsh 함수6.04~6.19ms0.11~0.17ms35.5~56.2×−97~98%
셸에서 send-keys, tmux vs zeropty bash builtin6.02~6.31ms0.043~0.048ms125~146×−99%
5.4MB 출력, 프로그램이 끝나기까지166~176ms3.0~4.6ms36.0~58.6×−97~98%
5.4MB 출력, 화면에 보이기까지169~188ms15~16ms10.5~12.5×−90~92%
세션 수 한도511개 (PTY 기본 한도, 최대 999)1000개 확인, PTY 한도 없음––

배수 계산 방법 ​

이 문서의 모든 배수와 줄어든 비율은 한 가지 규칙을 따라요.

  • 표에 적힌 값 범위의 양끝으로 계산해요. 아래 끝은 가장 빠른(작은) tmux 값 ÷ 가장 느린(큰) zeropty 값, 위 끝은 그 반대예요. 값이 하나면 배수도 하나예요. 회차끼리 짝지은 값은 남기지 않았으니, 표만 보고 누구나 같은 배수를 다시 계산할 수 있게 했어요.
  • 올리지 않고 버려요. 100배 미만은 소수 첫째 자리 아래를, 100배 이상은 소수점 아래를 버려요(13.96 → 13.9, 141.6 → 141). 줄어든 비율도 정수로 버려요. 단위가 섞이면(MB와 KB) 1MB = 1000KB로 계산해 작은 쪽 배수를 적어요.
  • 10배 판정도 이 규칙 하나로 해요. 범위의 아래 끝이 10배 이상이어야 ✅이고 README에 올라가요. 아래 끝이 10배 미만이면 위 끝이 10배를 넘어도 ⛔예요.
  • 참고로만 적는 값: bench/latency.mjs가 직접 출력한 배수(†). 스크립트는 회차마다 같은 회차의 반올림 전 p50끼리 나눈 배수를 출력해요(임베디드 열은 정수로, 소켓 열은 소수 첫째 자리로 반올림). Node 응답 속도 표의 오른쪽 세 열과 동기 클라이언트 배수가 그 세 회차의 범위예요. 회차별 p50 값은 남기지 않아서 표만으로는 다시 계산할 수 없으니, 판정에는 쓰지 않고 † 표시를 붙여 함께 적기만 해요. 예를 들어 임베디드 send-keys(tmux 실행 대비)는 회차별로 140~151×이고, 값 범위 양끝으로 나누면 128~159×예요. 키 입력 → 화면(tmux -C 대비, 임베디드)은 어떤 회차에서는 10×였지만(회차별 9~10×) 값 범위 양끝으로는 8.9~10.8×라 ⛔예요.

이 문서의 수치는 항상 최신 측정값이에요. 수치가 바뀌는 변경은 같은 커밋에서 이 문서와 README의 표를 갱신해요.

측정 환경

항목값
날짜2026-10-21 (서버 메모리·CPU, 서버 플러그인을 켜고 끈 임베디드 메모리, Node 응답 속도 세 번, 셸, 대량 출력, wait-pane, grep-panes), 2026-10-19 (C 클라이언트·서버 플러그인 응답 속도, polled 이벤트 루프 막힘)
커밋9694aa3에 이 변경의 bench/ 스크립트 (2026-10-21 측정)
기기Apple M1 (P코어 4 + E코어 4), 16GB
OSmacOS 26.6.2
비교 대상tmux 3.7c

측정 조건

  • 세션 설정: 220×80, 스크롤백 2000줄. tmux도 같은 크기이고, 스크롤백은 tmux 기본값 2000줄이에요.
  • 메모리: 20초 동안 2초마다 잰 footprint 10개의 평균이에요.
  • CPU: 같은 구간의 실제 경과 시간으로 나눈 값이에요.
  • 실제 작업 재생: 세션 i가 녹화본의 (i × 0.618 mod 1) 지점부터 시작해서, 회차와 서버가 달라도 같은 부하예요.
  • 같은 설정을 반복하면 메모리는 ±1.5%, CPU는 ±5% 정도 흔들려요(서버가 효율 코어에서 도는 일이 많아요).
  • 2026-10-21에는 기기에서 다른 프로그램이 돌고 있었어요(load average 약 2.9). 그래서 응답 속도와 셸 측정에서 tmux와 zeropty 모두 이전 날짜보다 느렸어요. 그래도 짝은 모두 같은 부하에서 번갈아 쟀고, 배수는 그 짝끼리 비교한 값이에요.

메모리 · CPU: 독립 서버 (bench/suite.sh) ​

부하세션tmux 메모리zeropty 메모리배수tmux CPUzeropty CPU배수
대기 (claude 화면 1장 출력 후 정지)2005.0~5.1MB1.20MB4.1~4.2× ⛔0%0%–
4008.0MB1.25~1.27MB6.2~6.4× ⛔0%0%–
화면 갱신 (1초마다 31줄 다시 그림)200121.0~121.3MB1.57~1.61MB75.1~77.2×18.21~18.31%1.02~1.12%16.2~17.9×
400251.2~251.4MB1.71~1.74MB144~147×28.71~29.79%1.85~2.09%13.7~16.1×
claude 기록 통째 출력 (15KB, 2초마다)2007.3~7.7MB1.71~1.73MB4.2~4.5× ⛔26.74~26.83%1.51~1.61%16.6~17.7×
40012.5~13.6MB1.95~2.30MB5.4~6.9× ⛔42.29~42.82%2.96~2.97%14.2~14.4×
실제 claude 작업 재생 (tests/streams/claude2.trace)20017.4MB1.65~1.66MB10.4~10.5×36.04~36.67%2.24~2.29%15.7~16.3×
40032.4~32.5MB2.66~2.70MB12.0~12.2×54.30~54.91%3.51~3.84%14.1~15.6×
대기1000불가 (PTY 한도)1.38~1.39MB–불가0%–
실제 claude 작업 재생1000불가 (PTY 한도)4.65MB–불가6.69~7.10%–

2026-10-21에 bench/suite.sh를 두 번 돌린 값이고, 범위는 두 회차예요. 여기서 MB는 footprint KB ÷ 1000이에요.

  • 실제 작업 200개가 이제 10배를 넘어요(2026-10-19에는 8.4~9.0×). tmux는 17.4MB 그대로인데 zeropty가 1.92~2.05MB에서 1.65~1.66MB가 됐어요. claude 기록 통째 출력 200개의 CPU는 경계(9.9~10.2×)에서 16.6~17.7×가 됐어요. tmux CPU가 늘고(17.9% → 26.74~26.83%) zeropty는 줄었어요(1.75~1.80% → 1.51~1.61%). 둘 다 원인은 찾아보지 않았어요.

세션 1000개의 출력이 다 흐르게 고쳤어요(2026-10-18). 이전 측정(4.66%)은 출력의 59%만 흐르던 상태였어요. 원인은 macOS 파이프였어요.

  • macOS는 파이프 버퍼를 시스템 전체 16MB에서 나눠 줘요. 64KB까지 커진 파이프가 약 250개 생기면, 나머지 파이프는 모두 512바이트만 받아요(직접 만든 파이프 3000개로 확인).
  • 512바이트 파이프는 fstat이나 kevent로도 구별되지 않아요(둘 다 16KB라고 해요). 그런 세션의 프로그램은 300ms 읽기마다 512바이트만 쓰고 기다렸어요.
  • 세션 출력을 socketpair(버퍼 64KB, 세션마다 따로)로 바꾸자, 1000개 재생에서 서버가 읽은 양이 녹화 속도 대비 59%에서 **102%**가 됐어요. 200·400개는 전후 모두 101%예요.
  • 그래서 1000개의 CPU는 더 많은 출력을 처리한 값이에요.
  • 대가: zeropty가 읽기를 멈추면 세션마다 최대 64KB가 커널 소켓 버퍼(mbuf)에 쌓여요. 1000개면 64MB예요(이 기기의 mbuf 풀은 256MB). 파이프는 시스템 전체 16MB가 상한이었어요.

⛔ 표시는 서버 프로세스 하한 때문에 10배가 안 되는 항목이에요. 세션 1개인 zeropty 서버도 1.09MB예요(2026-10-19). 아무것도 안 하는 C 프로그램이 0.87MB, perl 프로세스가 1.36MB, tmux 빈 서버가 2.2MB예요.

  • 빈 C 프로그램 0.865MB도 대부분 운영체제 몫이에요(footprint로 분해): 커널 페이지 테이블 209KB(XNU는 이것도 footprint에 넣어요), 시스템 malloc 메타데이터 약 200KB, 라이브러리가 고쳐 쓴 데이터(__DATA_DIRTY, __DATA, shared cache) 약 240KB, 스택과 malloc 영역. 링크 옵션(-no_fixup_chains, -dead_strip, 작은 스택)으로는 잡음 수준(±16KB)만 움직였고, Apple의 /bin/sleep도 832KB예요.
  • 줄일 수 있었던 건 시스템 malloc 설정이에요(2026-10-19 적용). 서버는 MallocNanoZone=0 MallocMaxMagazines=1 MallocSpaceEfficient=1 MallocMediumZone=0으로 자신을 다시 실행해요(libmalloc은 시작할 때만 읽어요). 화면은 mimalloc이 맡고 시스템 malloc은 libSystem 몫만 하니, 스레드 하나에 맞게 영역과 CPU별 캐시를 줄여도 돼요. 모든 부하에서 144~240KB가 줄었어요(세션 1개 1233 → 1089KB, 대기 200개 1.39~1.44 → 1.23MB, 실제 작업 400개 2.96~3.04 → 2.72~2.82MB, 번갈아 2회). CPU는 같은 분포였어요(실제 작업 400개, 번갈아 3회: 1.47/1.53/4.01% vs 1.47/3.85/3.86%, 서버가 어느 코어에서 도느냐로 두 값을 오가요). 세션의 프로그램에는 이 설정을 물려주지 않아요.
  • zeropty 몫은 mimalloc이 잡은 384KB예요. 실제 힙은 8KB인데, 오래 사는 작은 객체들이 크기 등급별로 다른 16KB 쪽에 흩어져 쪽마다 통째로 잡혀요. mimalloc 설정(eager commit, arena 크기)은 효과가 없었어요. MallocNanoZone=0 하나만으로는 16KB뿐이고, 위의 네 설정을 함께 써야 줄어요. 예를 들어 대기 400개가 10배가 되려면 0.80MB 이하여야 해서 빈 프로세스보다 작아야 해요.

서버를 다른 언어로 쓰면 넘을 수 있을까요? 빈 서버를 직접 만들어 쟀어요(2026-10-18, Mac mini M1).

빈 서버메모리
시스템 콜만 쓰는 정적 바이너리 (libSystem 없음)실행 불가: Apple Silicon macOS 커널이 바로 SIGKILL해요
C, pause()만 / kqueue와 소켓 서버0.865MB / 0.865MB
Rust, 빈 main0.93MB
zeropty 빈 서버1.15MB
Go, 소켓 서버2.59MB

C로 다시 짜면 빈 서버에서 약 0.29MB를 아낄 수 있어요. 하지만 C 빈 프로세스(0.865MB)가 이미 대기 400개의 10배 기준(0.80MB)보다 커요. 그래서 별도 프로세스로는 어떤 언어로도 불가능해요. 이 하한을 넘는 방법이 아래 임베디드 모드예요. 따로 프로세스를 두지 않으니 프로세스 하한이 없어요.

세션 수가 200에서 400으로 늘 때 세션 하나당 늘어난 메모리 (독립 서버):

부하tmuxzeropty배수
대기14.9~15.0KB0.24~0.32KB46.5~62.5×
화면 갱신649~652KB0.54~0.83KB781~1207×
claude 기록 통째 출력23.8~31.8KB1.1~3.0KB7.9~28.9× ⛔
실제 claude 작업74.8~75.3KB5.0~5.3KB14.1~15.0×

두 회차(2026-10-21)의 footprint KB로 계산했고, 다른 곳처럼 범위 양끝을 짝지었어요. claude 기록 통째 출력이 ⛔인 건 zeropty 400개 footprint가 두 회차에서 352KB 달랐기 때문이에요(1948KB와 2300KB). 2026-10-19에는 13.2×였어요.

PTY 사용량: 세션 400개일 때 tmux는 435개, zeropty는 35개예요. 35개는 시스템 기본값이라 세션 1000개에서도 그대로예요.

메모리: 임베디드 (엔진을 내 프로세스 안에, bench/embed_host.c) ​

다른 일을 하지 않는 C 프로그램에 엔진을 넣고, 엔진을 켜기 전과 세션을 띄운 뒤의 footprint 차이를 쟀어요. Node처럼 자체 힙이 수 MB씩 움직이는 호스트에서는 이렇게 정확히 잴 수 없어서 C 호스트를 써요.

엔진은 두 방식으로 돌 수 있어요. 엔진 스레드(zeropty_open, Node { polled: false })는 엔진이 자기 스레드를 가져요. polled(zeropty_open_polled, Node 기본값)는 스레드 없이 호스트의 이벤트 루프가 엔진을 돌려요. 값은 2026-10-21에 방식마다 두 번 잰 범위이고, tmux 서버 열은 위 독립 서버 표의 값이에요.

부하세션tmux 서버zeropty 엔진 스레드배수zeropty polled배수
대기2005.0~5.1MB0.68~0.69MB7.2~7.5× ⛔0.39MB12.8~13.0×
4008.0MB0.74MB10.8×0.45~0.46MB17.3~17.7×
실제 claude 작업 재생20017.4MB1.65~1.66MB10.4~10.5×0.95~1.32MB13.1~18.3×
40032.4~32.5MB2.25~2.33MB13.9~14.4×1.63~1.64MB19.7~19.9×
  • 엔진 스레드를 켜는 것만으로 +308~309KB예요. 스레드의 할당기 힙, 스택, 페이지 테이블 몫이에요. polled는 +180~181KB예요.
  • polled 대기 200개가 0.31MB(16.4×, 2026-10-18)에서 0.39MB(12.8~13.0×)가 됐어요. 엔진 시작이 +149KB에서 +180~181KB로, 세션 200개 몫이 +160KB에서 +208KB로 늘었어요. 그 사이 어떤 변경이 늘렸는지는 아직 찾지 못했어요.
  • polled는 세션 몫도 작아요. 모든 할당이 호스트 스레드의 힙 하나에 모이기 때문이에요(대기 200개: 368~384KB → 208KB).
  • polled의 비용: 세션 출력 파싱을 호스트 스레드가 해요(실제 작업 400개에서 코어 하나의 ~3.5%). 명령은 모두 그 스레드에서 불러야 해요(Node는 원래 JS 스레드 하나라 제약이 없어요. worker 스레드에서 부를 때만 { polled: false }).
  • 대량 출력은 조각내서 파싱해요. polled는 출력 덩어리를 256KB씩, 한 번에 최대 2ms만 읽고 호스트 루프에 차례를 돌려줘요. 바쁜 세션 200개에 5.4MB 출력이 들어올 때 Node 이벤트 루프가 가장 길게 막힌 시간이 11~32ms에서 3~6ms가 됐어요(bench/loop_block.mjs, 번갈아 3회). 화면에 다 들어오는 시간과 메모리는 그대로예요. 엔진 스레드 방식도 CPU를 두고 다투느라 최대 30ms 막혔어요.
  • 세션 하나당은 대기 0.24~0.32KB, 실제 작업 3.0~3.4KB예요(엔진 스레드 방식, 세션 200 → 400개).
  • 트레이드오프: 10초 넘게 조용한 화면은 zstd로 압축해 이름 없는 임시 파일에 둬요. 프로세스 메모리에서는 빠지지만 OS 페이지 캐시에는 남아요(필요하면 OS가 회수해요). ZEROPTY_COLD=memory로 메모리에 두게 할 수 있어요.

서버 플러그인을 켜면 (listen, bench/embed_host.c ... polled <소켓>, 2026-10-21, 켬·끔 번갈아 2회). 같은 C 호스트에서 소켓을 열었을 때와 안 열었을 때를 쟀어요.

부하세션tmux 서버 (위 표)플러그인 없음플러그인 켬tmux 서버 대비 (켬)
엔진만0–+180~181KB+180~181KB–
대기2005.0~5.1MB388KB388~389KB12.8~13.1×
실제 claude 작업 재생20017.4MB949~1322KB973~1248KB13.9~17.8×
40032.4~32.5MB1630~1636KB1395~1740KB18.6~23.2×
  • 플러그인 자체는 이제 0~1KB예요(2026-10-19에는 소켓 하나와 연결 목록으로 +16~18KB). 같은 기간에 polled 시작 비용이 약 31KB 늘었으니, listen이 더하던 것을 이제 엔진이 시작할 때 마련하는 것일 수 있어요. 확인하지는 않았어요. 실제 작업 부하의 차이는 회차마다 위아래로 바뀌는 잡음 범위예요.
  • 그래서 CLI·attach·소켓 클라이언트를 쓰면서도 메모리는 임베디드 수준이에요. 같은 대기 200개를 독립 서버로 두면 1.20MB(4.1~4.2× ⛔)예요. 차이는 서버 프로세스 하나의 몫이에요.
  • 주의: 이 값은 호스트 프로그램이 원래 있다는 전제의 증가분이에요(호스트 자체 ~1.08MB 제외). 세션만 두려고 호스트를 따로 띄운다면 그건 독립 서버와 같아요.

응답 속도 (node bench/latency.mjs 200) ​

Node 프로그램이 세션 서버를 부르는 네 가지 방식을 나란히 쟀어요.

방식설명
tmuxNode execFile로 명령마다 tmux 실행 (프로그램이 tmux를 부르는 흔한 방식)
tmux -C제어 모드: tmux 클라이언트 하나를 계속 열어 두고 stdin으로 명령 전송 (tmux를 가장 빠르게 쓰는 방법)
zeropty연결 하나를 유지하며 서버 소켓에 직접 요청 (client/zeropty.mjs)
zeropty 임베디드엔진을 Node 프로세스 안에 넣고 함수로 호출 (client/embedded.mjs)

서버마다 바쁜 세션 200개를 띄우고, 요청을 번갈아 보냈어요. 항목마다 40회씩이고, 2026-10-21에 세 번 실행한 p50(ms)의 범위예요. 임베디드 열은 Node 기본값인 polled 방식이에요. 오른쪽 세 배수 열은 bench/latency.mjs가 회차마다 출력한 배수의 범위(†)예요. 10배 판정은 그 아래 표의, 값 범위 양끝으로 나눈 배수로 해요(배수 계산 방법).

항목tmuxtmux -Czeroptyzeropty 임베디드임베디드 vs tmux †임베디드 vs tmux -C †zeropty vs tmux -C †
capture-pane (바쁜 세션)9.31~9.900.55~0.630.25~0.300.10~0.1757~93×3~5× ⛔1.8~2.5× ⛔
capture-pane -e -S -200 (조용한 세션)9.37~10.140.45~0.600.23~0.270.06~0.07134~172×7~10× ⛔2.0~2.3× ⛔
send-keys9.03~9.560.42~0.490.24~0.260.06~0.07140~151×7~8× ⛔1.7~2.0× ⛔
키 입력 → 화면에 보이기까지24.62~25.240.89~0.980.35~0.430.09~0.10250~268×9~10× ⛔ (값 범위로는 8.9~10.8×)2.1~2.8× ⛔

같은 p50 범위를 한 가지 규칙(값 범위의 양끝으로 나누고 버림)으로 계산한 배수예요. 10배 판정은 이 표로 해요.

항목zeropty vs tmux임베디드 vs tmux임베디드 vs tmux -Czeropty vs tmux -C
capture-pane (바쁜 세션)31.0~39.6×54.7~99.0×3.2~6.3× ⛔1.8~2.5× ⛔
capture-pane -e -S -200 (조용한 세션)34.7~44.0×133~169×6.4~10.0× ⛔1.6~2.6× ⛔
send-keys34.7~39.8×128~159×6.0~8.1× ⛔1.6~2.0× ⛔
키 입력 → 화면에 보이기까지57.2~72.1×246~280×8.9~10.8× ⛔2.0~2.8× ⛔
  • 임베디드 열은 polled 방식이고, 엔진 스레드 방식도 같은 수준이에요. 2026-10-21에 번갈아 두 번씩 잰 polled 대 엔진 스레드 p50: 바쁜 세션 capture 0.08~0.09 vs 0.08~0.11, 조용한 세션 capture 0.06~0.07 vs 0.05~0.07, send-keys 0.06 vs 0.06, 키→화면 0.10 vs 0.08~0.09. 그러니 임베디드 send-keys가 0.04~0.06ms(2026-10-18, 엔진 스레드)에서 0.06~0.07ms가 된 건 방식 때문이 아니에요. 기기가 더 바빴고, 더 확인하지는 않았어요.

  • 임베디드 send-keys(tmux -C 대비)가 라인 디시플린을 넣으며(2026-10-18) 회차별 10~11배에서 8~10배가 됐고(†, ⛔), 2026-10-21에는 회차별 7~8배예요. 측정 세션(cat)이 이제 PTY처럼 cooked 모드예요. 그래서 키마다 모드 확인 읽기 한 번과 에코 처리가 더해졌어요(키 하나에 약 10µs). tmux도 같은 에코를 처리하지만 서버 쪽에서 send-keys가 돌아간 뒤에 해요. raw 모드 세션(claude)에는 더해지는 일이 없어요(examples/sendkeys_bench.rs, 변경 전과 같음). shim이 모드를 출력 대신 공유 메모리로 알려 주면 모드 확인 읽기를 없앨 수 있지만 넣지 않았어요. 얻는 건 cooked 세션의 키 하나당 약 8µs(25ms 간격, cooked 22.9µs vs raw 14.7µs)뿐이고, 실제 대상인 claude는 이미 raw예요.

  • 임베디드 capture-pane(바쁜 세션)이 크게 흔들리는 이유를 쪼개 봤어요(회차별 2026-10-18 5~12배, 2026-10-21 3~5배. 분석은 2026-10-18, 바쁜 세션 200개).

    • 같은 화면을 연달아 capture하면 5.9µs예요. 25ms 간격으로 부르면 렌더링만 27µs예요. 쉬다 깨어난 CPU의 클럭과 캐시 때문이에요. 세션 1개로 재도 같았고, 부르는 스레드의 QoS를 올려도 거의 같았어요.
    • 바쁜 세션은 300ms 동안 모은 출력을 capture가 그 자리에서 파싱해요. p50 15~30µs, 8~12KB가 쌓였으면 최대 0.5ms예요. 엔진 스레드가 다른 세션을 읽는 중이면 잠금을 기다려요(p99 최대 0.5ms).
    • 바뀌지 않은 화면의 capture 결과를 저장해 두면 식은 상태에서도 렌더링이 빠지지만, 넣지 않았어요. 화면 하나에 약 17KB라 세션 1000개면 17MB가 늘어요.
    • 최근 capture한 세션을 바로 읽게 하면 이 부분이 사라지지만, 넣지 않았어요. 폴링하는 호스트는 폴링 간격(수백 ms)이 지배적이라 체감 이득이 없고, CPU가 늘어요(바로 읽기: 세션 200개에 +0.8%p). 빨리 알아야 하는 경우는 wait-pane이 있어요.
  • 배수가 흔들리는 이유: tmux -C가 0.4~0.6ms, 임베디드가 0.05ms 안팎이라 0.01ms 차이에도 배수가 2~3씩 움직여요. 그래서 한 번의 값이 아니라 범위로 적었어요. 바쁜 세션 capture는 그동안 쌓인 출력을 먼저 읽고 화면을 그리는 비용이 들어 있어요.

  • zeropty 소켓 방식은 명령마다 tmux 실행 대비 31.0~72.1배예요(위 두 번째 표의 첫 열).

  • 소켓 방식(zeropty)은 tmux -C 대비 1.6~2.8배예요(⛔, 위 두 번째 표의 마지막 열. 회차별로는 1.7~2.8× †). Node에서 Unix 소켓 왕복 자체가 하한이에요(아무 일도 안 하는 에코 서버가 0.33ms).

  • C 프로그램에서 재도 비율은 1.7~3.3배예요(bench/latency_c.c, 2026-10-18). Node를 빼면 tmux -C도 같이 빨라지기 때문이에요.

C 클라이언트에서 (p50)tmux -Czeropty 소켓배수
send-keys22~35µs10.5~12.5µs1.7~3.3× ⛔
capture-pane (바쁜 세션)40µs13~15µs2.6~3.0× ⛔

둘 다 프로세스 사이 왕복 한 번이 바닥이에요. tmux -C 대비 10배가 되려면 3.5µs 이하여야 하는데, 소켓 왕복 하나(문맥 전환 두 번)가 그보다 커요. 그래서 언어를 바꿔도 안 되고, 왕복을 없애는 임베디드 모드만 10배를 넘어요.

  • 위 표는 요청을 쉬지 않고 연달아 보낸 값이에요. 실제로는 요청 사이에 쉬는 시간이 있어요. 그래서 요청 전에 쉬는 간격을 두고 다시 쟀어요(latency_c ... 300 <간격 µs>, 2026-10-19, 바쁘지 않은 cat 세션이 아니라 claude 재생 세션). 쉬었다 보내면 둘 다 느려지고 배수는 1.7배로 줄어요.
C 클라이언트, p50tmux -Czeropty 소켓배수
send-keys, 연달아26.1µs9.3µs2.8× ⛔
send-keys, 1ms 쉬고65.5µs36.7µs1.7× ⛔
send-keys, 25ms 쉬고99.2µs56.4µs1.7× ⛔
capture-pane, 25ms 쉬고108.2µs62.3µs1.7× ⛔
  • 서버 플러그인(임베디드 호스트가 연 소켓)도 독립 서버와 같은 속도예요. bench/listen_host.c(polled 엔진이 소켓을 엶)와 zeropty serve를 같은 C 클라이언트로 번갈아 2회 쟀어요(2026-10-19). 이날은 기기가 더 느린 상태여서 tmux -C도 위 표의 2배쯤 나왔고, 배수는 비슷해요.
C 클라이언트, p50tmux -C독립 서버서버 플러그인
send-keys, 연달아26.6~44.6µs12.0~20.4µs12.4~22.2µs
capture-pane, 연달아41.5~74.1µs14.6~27.3µs14.2~28.7µs
send-keys, 25ms 쉬고224~252µs120~124µs (1.8~2.1×)126~130µs (1.7~2.0×)
capture-pane, 25ms 쉬고297~360µs143~149µs (1.9~2.5×)148~160µs (1.8~2.4×)

쉬었다 보낼 때 플러그인이 3~8% 느려요. 원인은 아직 확인하지 않았어요. polled 호스트는 엔진 fd를 poll로 기다린 뒤 zeropty_process에서 kqueue를 한 번 더 읽어서, 시스템 호출이 하나 더 있어요.

  • 쉬었다 보낼 때의 바닥은 "통로"가 아니라 잠든 코어를 깨우는 비용이에요(2026-10-19, C로 직접 잼). 두 프로세스가 1바이트를 주고받는 왕복을 방식별로 쟀어요. 연달아 보내면 방식마다 0.7~10µs로 다르지만, 1ms만 쉬어도 모두 12~21µs, 25ms 쉬면 28~34µs가 돼요. 코어를 계속 돌리는 spin만 0.08µs인데, 코어 하나를 100% 써요.
왕복 방식 (p50)연달아1ms 쉬고25ms 쉬고
socketpair (zeropty가 쓰는 방식)9.7µs17.6µs33.6µs
pipe5.6µs12.2µs28.2µs
공유 메모리 + __ulock 대기6.5µs20.5µs28.2µs
공유 메모리 + 200µs spin 후 대기0.7µs13.3µs28.3µs
mach_eventlink (커널이 상대 스레드로 바로 넘김)2.1µs14.0µs–
공유 메모리 + 계속 spin0.08µs0.08µs(코어 100%)

tmux -C가 25ms 쉬고 99µs이니 10배는 9.9µs인데, 이 바닥(28~34µs)보다 작아요. 그래서 쉬었다 보내는 조건의 10배는 별도 프로세스로는 불가능해요.

  • 서버 안의 처리 시간도 식어서 늘어요. 25ms 쉬고 보낸 has-session에서 서버가 요청을 받아 답을 쓰기까지 18.5µs였어요(연달아 보내면 2.4µs). 소켓 읽기, JSON 해석, 명령 실행, 답 쓰기가 골고루 6~7배 느려졌어요. 특정 코드가 아니라 식은 CPU와 캐시 때문이에요.
  • 시도했지만 넣지 않은 것 (2026-10-19, 모두 번갈아 측정):
    • 서버 스레드 QoS를 USER_INTERACTIVE로: 37.5~39.9µs vs 39.1~39.2µs, 차이 없음.
    • LTO + codegen-units=1(코드를 모아 식은 상태에서 덜 건드리게): 1~2µs(3%), 잡음 수준.
    • kqueue가 알려 준 바이트 수만큼만 읽어서 "더 없음" 확인 읽기를 생략: 차이 없음.
    • 답한 뒤 서버가 200µs 동안 다음 요청을 기다리며 spin: 연달아 보내는 구간의 send-keys가 8.0µs에서 4.5µs(tmux -C 대비 5.8배)가 되지만, 쉬었다 보내면 그대로예요. 대신 요청마다 코어를 200µs씩 붙잡아 CPU 목표를 깎아요. 공유 메모리로 바꾸면 계산상 1.5~2µs까지 가지만 역시 연속 구간에서만이라, µs 몇 개는 사람이나 claude의 속도에서 체감되지 않아요.
    • mach_eventlink: 스레드 하나가 링크 하나에서만 기다리는 방식이라 kqueue 하나로 모든 연결을 보는 서버에 넣으려면 연결마다 스레드가 필요해요(스레드마다 할당기 힙, 앞의 GCD 시험에서 +0.8MB). kqueue와 함께 쓸 수 있는 Mach 메시지는 소켓보다 2µs 빠를 뿐이에요.
  • 클라이언트에 화면 미러를 두는 방식(mosh, tmux %output처럼)은 넣지 않았어요(2026-10-19). 클라이언트가 attach -r로 출력을 받아 화면을 직접 해석하면 capture에 왕복이 없어져요. 하지만 바쁜 세션 200개를 모두 미러링하자 서버 CPU가 2.50%에서 6.15%가 됐어요. 누가 보는 세션은 300ms씩 모아 읽지 않고 바로 읽어서 보내기 때문이에요. 클라이언트도 초당 573KB를 다시 해석해야 해요. 그리고 미러의 capture는 서버 capture와 달리 쌓인 출력을 확인하지 않아서, 빠른 만큼 오래된 화면일 수 있어요. 모아 읽기를 유지하면 CPU는 그대로지만 화면이 최대 300ms 늦어요. 화면을 계속 받는 용도는 attach -r, 변화를 기다리는 용도는 wait-pane이 이미 해요.
  • 동기 클라이언트(zeroptySync, zeropty.node의 Client)는 회차별 3.0~4.1배예요(⛔ †, p50 0.13~0.26ms, 같은 세 번). 이벤트 루프를 거치지 않고 블로킹으로 쓰고 읽어서 p50이 비동기보다 약 25~48% 낮아요. 남은 하한은 서버와 Node 두 프로세스가 번갈아 깨어나는 왕복이에요. 서버가 바쁠 때는 p95가 1ms까지 튀어요.
  • 임베디드는 그 하한이 없어요. 남은 하한은 Node 스레드가 잠들었다 깨는 시간(~40~50µs)이에요. 연달아 부르면 한 번에 2.6µs예요.

응답 속도: 셸에서 (bench/shell.zsh) ​

셸 스크립트에서 명령 200번씩, 바퀴마다 번갈아 쟀어요. bench/shell.zsh를 세 번, 한 번에 두 바퀴씩 모두 여섯 바퀴예요(2026-10-21, Mac mini M1). 단위 ms(p50 / p95)이고, 범위는 여섯 바퀴의 값이에요.

명령tmuxzeropty 프로그램zeropty zsh 함수zsh 함수 vs tmux
has-session6.04~6.19 / 6.74~7.203.59~3.95 / 4.22~4.850.11~0.17 / 0.14~0.3835.5~56.2×
send-keys -l6.11~6.33 / 6.92~9.863.68~3.92 / 4.25~5.390.12 / 0.15~0.2050.9~52.7×
capture-pane -p -S -20006.67~6.75 / 7.41~7.763.86~4.23 / 4.47~5.520.50~0.55 / 0.57~1.1312.1~13.5×
  • 두 프로그램 모두 2026-10-18보다 오래 걸렸어요(tmux 4.35~5.02ms → 6.04~6.75ms, zeropty 프로그램 2.47~2.60ms → 3.59~4.23ms). 기기가 더 바빴어요. zsh 함수는 거의 그대로라(0.11~0.48ms → 0.11~0.55ms) 배수가 올라갔어요.
  • 프로그램끼리는 1.5~1.7배예요. 명령마다 프로세스를 띄우는 비용(빈 C 프로그램도 1.3ms)이 하한이에요.
  • zsh 함수(shell/zeropty.zsh)는 그 하한을 피해요. zsh에 기본으로 들어 있는 소켓 모듈(zsh/net/socket)로 셸 안에서 서버에 바로 말해서, 프로세스를 띄우지 않아요.
  • bash는 builtin(shell/zeropty_bash.c → dist/zeropty_bash.dylib)으로 같은 일을 해요. bash 3.2에는 µs 시계가 없어서 200번 도는 루프 전체 시간에서 빈 루프 시간을 뺀 평균이에요(같은 스크립트 끝부분).
명령 (bash, 평균, 여섯 바퀴)tmuxzeropty 프로그램zeropty bash builtinbuiltin vs tmux
has-session5.968~6.470ms3.630~3.860ms0.041~0.075ms79.5~157×
send-keys -l6.022~6.313ms–0.043~0.048ms125~146×
capture-pane -p -S -20006.733~7.066ms–0.114~0.138ms48.7~61.9×

위와 같은 여섯 바퀴예요. builtin has-session이 한 바퀴에서 2.448ms가 나왔고 나머지 다섯 바퀴는 0.041~0.075ms였어요. 그 바퀴의 다른 명령은 정상이라 기기가 잠깐 멈춘 것으로 보고(확인하지는 않았어요) 범위에서 뺐어요. 넣으면 아래 끝이 2.4×예요.

대량 출력 ​

프로그램이 5.4MB(색상 포함 6만 줄)를 최대한 빨리 출력해요(bench/bulk.sh, 2026-10-21, Mac mini M1, tmux와 번갈아 3번씩, 두 번).

항목tmuxzeropty배수
프로그램이 출력을 끝내기까지166~176ms3.0~4.6ms36.0~58.6×
프로그램이 출력을 시작해서 마지막 줄이 capture-pane에 보이기까지169~188ms15~16ms10.5~12.5×
  • 화면 시간이 10배를 넘은 건 tmux가 느려졌기 때문이에요. 2026-10-18에는 159~164ms 대 zeropty 14~16ms(9.9~11.7×, 경계)였어요. zeropty는 두 날 모두 같은 시간이 걸렸어요.

  • 출력이 파이프를 채우면 zeropty는 프로그램이 멈출 때까지 전부 먼저 읽고, 파싱은 그다음에 해요. 그래서 프로그램이 파싱을 기다리지 않아요. 이전에는 16KB씩 읽고 파싱하는 동안 프로그램이 막혀서 17ms였어요. 하한은 같은 프로그램이 cat > /dev/null에 쓸 때의 1.6ms예요.

  • 화면 쪽은 스크롤되어 나가는 행의 압축을 한 번의 쓰기가 끝날 때까지 미뤄요. 6만 줄 중 스크롤백(2000줄)에 남는 행만 압축해요. 5.4MB 파싱이 16.5ms에서 약 9ms가 됐어요(cargo run --release --example parse_bench bulk).

  • 화면 시간은 capture-pane을 쉬지 않고 불러서 재요. 매번 프로세스를 띄워서(zeropty ~2.5ms, tmux ~4.5ms) 그만큼 오차가 있어요. 이전 측정(31~41ms)에는 new-session과 시각을 재는 perl 실행 비용이 더해져 있었어요.

기다리기: 폴링 대신 wait-pane (node bench/wait.mjs 200) ​

호스트가 "세션이 일을 끝냈는지"를 알아내는 두 방식을 비교했어요. 세션 200개가 실제 claude 작업(tests/streams/claude2.trace)을 5~25초 재생한 뒤 TASK-DONE을 출력해요.

  • 폴링: 호스트가 남은 세션을 P ms마다 capture-pane으로 읽어요. tmux 호스트는 이 방법밖에 없어요.
  • wait-pane: 세션마다 wait-pane -m TASK-DONE을 한 번 보내고 답을 기다려요.

2026-10-21, Mac mini M1에서 두 바퀴 번갈아 측정했어요. "감지 지연"은 줄이 출력된 순간부터 호스트가 알아챈 순간까지예요.

방식서버 CPU호스트(Node) CPU감지 지연 p50 / p95
500ms 폴링2.0%0.6%253~262 / 479~489ms
200ms 폴링2.9~3.0%1.1~1.3%86~96 / 173~196ms
wait-pane3.8~3.9%0.3~0.4%1 / 2ms
  • 알아채는 시간이 86~262배 짧아요(p50 86~262ms → 1ms). "프롬프트가 뜨면 다음 입력 보내기"를 반복하는 자동화는 단계마다 그만큼 빨라져요. 호스트에서 폴링 코드도 사라져요.
  • CPU는 공짜가 아니에요. 서버 CPU가 200ms 폴링보다 0.8~1.0%p 늘어요(세션 200개). 기다리는 세션은 출력을 300ms씩 모으지 않고 바로 읽기 때문이에요. 같은 세션을 모으기 없이 읽기만 해도 서버 CPU가 2.0%에서 2.8%로 늘었어요. 대기가 끝나면 원래대로 돌아가요.
  • 글자 검사는 이번 읽기가 바꾼 행만 해요. 처음 만든 판은 읽을 때마다 화면 전체를 글자로 바꿔 검사해서 서버 CPU가 4.4~6.3%였어요. 바뀐 행만 보게 해서 2.7~3.2%가 됐고, 남은 비용은 바로 읽는 비용이에요. 화면 전환(대체 화면에서 나올 때)은 예전 행이 다시 보이므로 전체를 검사해요.
  • 출력을 모아 읽는 그대로 기다리게 해 봤더니 서버 CPU는 폴링보다 15% 적었지만, 감지 지연이 p50 340ms로 500ms 폴링보다 늦었어요. 그래서 바로 읽는 방식을 택했어요.

여러 세션에서 글자 찾기: grep-panes (bench/grep.zsh 1000) ​

대기 중인 claude 화면 1000개 중 3개에 표시를 넣고, 그 3개를 찾는 데 걸린 시간이에요(2026-10-21, Mac mini M1, bench/grep.zsh 두 번, 한 번에 두 바퀴씩).

방법시간서버 메모리 (전 1.41MB)
CLI로 세션마다 capture-pane4.2~4.3초1.70~2.05MB
zsh 함수로 세션마다 capture-pane0.88~1.02초1.71~1.81MB
Node(client/zeropty.mjs)로 세션마다 capture-pane92~118ms2.11~2.48MB
grep-panes16~32ms1.60~1.76MB
  • 터미널에서 "어느 세션이 입력을 기다리지?"가 약 4초에서 16~32ms가 돼요.
  • 방법마다 잰 메모리에는 앞 바퀴들이 펼쳐 둔 화면이 들어 있어요. grep-panes는 바퀴마다 맨 먼저 돌고, 첫 바퀴 값은 1.60~1.63MB예요.
  • 화면을 녹이지 않아요. capture-pane은 읽은 화면을 다시 얼 때까지 펼쳐 두지만(1000개면 +1MB 남짓), grep-panes는 언 화면의 사본을 읽어요.
  • tmux에는 화면 내용을 찾는 명령이 없어요(find-window -C는 대화형 선택 화면이에요).

10배 목표 현황 (전체) ​

항목상태
CPU: 200·400개의 모든 부하 (화면 갱신, claude 기록 통째 출력, 실제 claude 작업)✅ 13.7~17.9×
메모리: 화면 갱신, 실제 claude 작업 200·400개 (독립 서버)✅ 10.4~147×
메모리: 임베디드 대기 400개, 실제 작업 200·400개✅ 13.1~19.9× (polled), 10.4~14.4× (엔진 스레드, 대기 400개 10.8×)
메모리: 세션 하나당 증가분✅ 대기·화면 갱신·실제 작업 14.1~1207×. ⛔ claude 기록 통째 출력 7.9~28.9× (두 회차가 352KB 차이, 독립 서버 절 참고)
응답 속도: 명령마다 tmux 실행 대비✅ 소켓 31.0~72.1×, 임베디드 54.7~280× (회차별 57~268× †)
응답 속도: tmux 제어 모드 대비, 임베디드⛔ 키→화면 8.9~10.8× (회차별 9~10× †), send-keys 6.0~8.1× (cooked 세션의 에코), capture 3.2~10.0× (원인은 위 응답 속도 절). 범위의 아래 끝이 10배 미만이라 README에는 올리지 않아요
응답 속도: tmux 제어 모드 대비, 소켓⛔ 1.6~2.8× (비동기, 회차별 1.7~2.8× †), 3.0~4.1× (동기 클라이언트, †), 프로세스 간 왕복 하한
메모리 절대값: 독립 서버의 대기, claude 기록 통째 출력⛔ 4.1~6.9×, 서버 프로세스 하한 (빈 C 프로세스 0.87MB, 아래 메모리 절) (임베디드 polled로 넘음: 대기 12.8~17.7×, 실제 작업 13.1~19.9×. 서버 플러그인을 켜서 CLI·attach로 다뤄도 대기 200개 12.8~13.1×)
메모리: 임베디드 대기 200개✅ 12.8~13.0× (polled, Node 기본값). 엔진 스레드 방식은 ⛔ 7.2~7.5×, 스레드 비용 308~309KB
대량 출력: 프로그램이 출력을 끝내기까지✅ 36.0~58.6×
대량 출력: 화면에 보이기까지✅ 10.5~12.5× (2026-10-18에는 경계 9.9~11.7×. 이번엔 tmux가 느렸고 zeropty는 같았어요. 화면 시간에는 capture-pane을 프로세스로 띄우는 오차가 들어 있어요, 위 대량 출력 절)
응답 속도: 셸에서, zsh 함수 vs tmux CLI✅ 12.1~56.2×
응답 속도: 셸에서, bash builtin vs tmux CLI✅ 48.7~157× (프로그램끼리는 1.5~1.7×, 프로세스 실행 비용 하한)

측정 다시 하기 ​

sh
./build.sh
bash bench/suite.sh            # 독립 서버 메모리 · CPU, 약 15분
clang -O2 -o /tmp/embed_host bench/embed_host.c -Ldist -lzeropty_c -Wl,-rpath,$PWD/dist
/tmp/embed_host 400 trace $PWD # 임베디드 메모리 (idle | trace)
/tmp/embed_host 200 idle $PWD polled ~/.zeropty/eh.sock   # 서버 플러그인을 켠 임베디드 메모리
clang -O2 -o /tmp/listen_host bench/listen_host.c -Ldist -lzeropty_c -Wl,-rpath,$PWD/dist
/tmp/listen_host ~/.zeropty/lh.sock $PWD &   # 서버 플러그인 응답 속도: latency_c ~/.zeropty/lh.sock <tmux -L 이름> 400 [간격 µs]
node bench/latency.mjs 200     # 응답 속도
bench/bulk.sh zeropty; bench/bulk.sh tmux   # 대량 출력
bench/shell.zsh                # 셸에서 응답 속도 (zsh 함수, 프로그램, tmux)
node bench/wait.mjs 200        # 폴링 대 wait-pane (CPU, 감지 지연)
bench/grep.zsh 1000            # 세션 1000개에서 글자 찾기 (grep-panes 대 capture 반복)

세션 1000개 측정은 사용자당 프로세스 한도(kern.maxprocperuid, 기본 2666)를 확인한 뒤 진행해요. load.sh가 미리 확인하고 넘으면 멈춰요.

MIT 또는 Apache-2.0 라이선스로 배포해요.