Ghostty Devlog 006

Mitchell Hashimoto

Ghostty 개발 일지 006

원문은 Mitchell Hashimoto님이 에 게재했습니다. 이 블로그 구독하기

안녕하세요! Ghostty 👻 공식 개발 일지에 오신 것을 환영합니다!

이번 개발 일지는 속도 🚀에 집중합니다. 최근 몇 주간 Ghostty를 정말, 정말 빠르게 만들기 위해 적용한 다양한 방법을 보여드리려고 합니다. 터미널 에뮬레이터의 속도를 측정하는 방법은 여러 가지가 있습니다. 다른 블로그 글에서 각 방법을 정의할 수도 있겠지만, 오늘은 이번 개발 일지에서 선보이는 성능 작업이 모두 IO 처리량에 관한 것이라고만 말씀드리겠습니다.

IO 처리량이란 터미널 에뮬레이터 안에서 실행되는 프로그램(셸, neovim, tmux, cat 등)이 바이트를 터미널 에뮬레이터로 보내 처리되는 속도를 말합니다. 이 지표는 시끄러운 로그 출력을 tail로 지켜보는 일부터 실수로 대용량 파일을 터미널에 쏟아내는 일(누구나 한 번쯤 겪어봤을 겁니다)까지 현실 세계에 매우 직접적인 영향을 미칩니다.

이전 개발 일지를 놓쳤거나 Ghostty가 무엇인지 더 알고 싶다면, 이 웹사이트의 Ghostty 페이지를 참고해 주세요.


SIMD로 일반 텍스트 처리량 개선하기 (#1472)

프로그램은 단일 스트림(‘pty’)을 사용해 터미널 에뮬레이터와 통신합니다. 이 스트림에는 일반 텍스트 또는 제어 문자가 포함될 수 있습니다.

일반 텍스트는 터미널에 그대로 출력되며 있는 그대로 전송됩니다. 예를 들어 터미널에 “Hello, World!”를 쓰려면 프로그램이 Hello World!를 UTF-8로 인코딩하여1 pty에 씁니다.

제어 문자는 스타일 속성(전경색, 밑줄, 굵게 등) 설정, 커서 이동, 줄 삭제, 키보드 프로토콜 변경 등을 하는 데 사용됩니다. 제어 문자는 형식이 다양하지만 거의 모든 제어 문자2에 공통되는 한 가지는 이스케이프 문자(16진수 0x1B)로 시작한다는 점입니다.

단순하지만 전형적인 접근 방식은 for 루프에서 바이트를 읽는 것입니다:

const bytes = read();
for (byte in bytes) {
  if (byte == 0x1B) {
    // Process control sequence
  } else {
    // Process plain text
  }
}

이 방식은 한 번에 1바이트씩 살펴봅니다. 하지만 90년대 후반부터 컴퓨터는 SIMD 명령어(“Single Instruction Multiple Data”)를 이용해 한 번에 여러 바이트를 연산할 수 있게 되었습니다. Valve의 최신 Steam 하드웨어 설문조사에 따르면, 활성 컴퓨터의 99% 이상이 하나의 CPU 명령어로 16~32바이트를 한 번에 살펴볼 수 있는 기능을 지원합니다.

우리는 종종 컴파일러가 코드를 대신 최적화해주리라 기대하며 개별 CPU 명령어의 세부 사항까지 신경 쓰지 않습니다. 안타깝게도 컴파일러는 자동 벡터화에 매우 취약하며, 비교적 단순한 루프를 제외하면 효과적으로 자동 벡터화하는 경우가 드뭅니다. SIMD는 어셈블리(혹은 그에 준하는 코드)를 직접 작성했을 때 최첨단 최적화 컴파일러보다 훨씬 뛰어난 성능을 내는 드문 사례입니다.

위에서 제어 시퀀스를 찾는 작업을 SIMD 방식으로 하면 다음과 같습니다:

const bytes = read();
for (chunk of 16 bytes in bytes) {
  if (chunk contains 0x1B) {
    // Control sequence in here
  } else {
    // Chunk is entirely plain text
  }
}

위 두 예시에서 비교 연산에 걸리는 시간이 정확히 동일하다고 가정해 보겠습니다. SIMD 예시는 바이트를 16배 더 빠르게 처리할 것입니다. 현실은 그렇게 단순하지 않지만, SIMD를 통해 엄청난 속도 향상을 얻을 수 있습니다.

더 복잡한 점은 SIMD 명령어 집합이 CPU 세대에 따라 다르다는 것입니다. 프로그램의 바이너리 형태로 배포하면서 사용 가능한 최고의 기능을 활용하고 싶다면, 각 SIMD 명령어 집합마다 변형을 작성하고 각 형태를 컴파일한 뒤, CPU 기능 탐지(즉, cpuid 명령어)를 이용해 런타임에 올바른 버전을 선택해야 합니다.

예를 들어 위 코드는 “16바이트 청크”라고 표현합니다. 하지만 하드웨어에 따라 이는 16바이트(aarch64 neon 명령어 집합, 예컨대 Apple Silicon), AVX2에서는 32바이트, AVX512에서는 64바이트 등이 될 수 있습니다. 달라지는 것은 크기뿐만이 아니라 실제 CPU 명령어(이진 코드) 자체도 다릅니다. 따라서 코드를 여러 번 작성하거나 컴파일해야 합니다.

Ghostty는 이제 위의 두 번째 코드 샘플과 거의 같은 방식으로 SIMD를 사용합니다. 더불어 여러 바이트를 한 번에 디코딩하기 위해 SIMD를 이용해 UTF-8도 디코딩합니다3. Apple Silicon(M3 Max)에서 Ghostty는 이제 ASCII 입력을 7.3배 더 빠르게 처리합니다:

Benchmark 1: memcpy
  Time (mean ± σ):      52.7 ms ±   0.7 ms    [User: 41.6 ms, System: 47.6 ms]
  Range (min … max):    50.7 ms …  54.6 ms    53 runs

Benchmark 2: scalar
  Time (mean ± σ):     382.3 ms ±   4.0 ms    [User: 408.6 ms, System: 39.1 ms]
  Range (min … max):   376.1 ms … 388.7 ms    10 runs

Benchmark 3: simd (aarch64 neon)
  Time (mean ± σ):      52.3 ms ±   0.6 ms    [User: 53.8 ms, System: 47.2 ms]
  Range (min … max):    51.3 ms …  54.2 ms    54 runs

Summary
  simd ran
    1.01 ± 0.02 times faster than memcpy
    7.32 ± 0.11 times faster than scalar

그리고 Ghostty는 이제 UTF-8을 읽어 UTF-32로 디코딩하는 속도가 16.6배 더 빨라졌습니다:

Benchmark 1: memcpy
  Time (mean ± σ):      22.3 ms ±   0.8 ms    [User: 11.5 ms, System: 31.4 ms]
  Range (min … max):    20.2 ms …  25.5 ms    115 runs

Benchmark 2: scalar
  Time (mean ± σ):     344.1 ms ±   1.2 ms    [User: 344.4 ms, System: 38.3 ms]
  Range (min … max):   341.9 ms … 347.0 ms    10 runs

Benchmark 3: simd (aarch64 neon)
  Time (mean ± σ):      20.7 ms ±   0.8 ms    [User: 18.9 ms, System: 28.0 ms]
  Range (min … max):    19.1 ms …  24.4 ms    127 runs

Summary
  simd ran
    1.07 ± 0.06 times faster than memcpy
   16.60 ± 0.61 times faster than scalar

x86_64 머신에서 AVX2를 사용할 수 있는 경우 결과는 더욱 극적이지만, 위와 같은 정확한 출력을 재현할 수 있는 x86_64 머신에 당장 접근할 수 없어 테스트 커뮤니티에서 공유된 일화만 전해드립니다.

memcpy 벤치마크는 바이트를 미리 할당된 버퍼로 읽어들이는 것에 불과합니다. 즉, 기본적으로 for { bytes = read() }와 같습니다(컴파일러가 이를 최적화로 제거하지 못하도록 약간의 어노테이션을 추가했습니다). 즉, 위 두 경우 모두 우리의 SIMD 파이프라인이 텍스트를 읽고(UTF-8의 경우) 파싱하는 속도가 단순히 데이터를 메모리에 읽어들이는 속도와 거의 동일하다는 의미입니다.

실제 wall time 기준으로, 이러한 최적화 덕분에 대용량 ASCII 파일을 cat하는 속도는 2배 빨라졌고, 대용량 일본어 파일을 cat하는 속도는 20% 빨라졌습니다. 속도 향상 폭이 위 벤치마크보다 훨씬 작게 나타난 이유는 다른 병목 때문입니다. 좋은 소식은, 그 병목들도 찾아내 최적화했다는 점입니다. 계속 읽어보세요!


코드포인트 너비를 위한 사전 계산된 룩업 테이블 (#1486)

터미널은 고정폭 셀들의 격자로 이루어져 있습니다. A 같은 문자를 터미널에 쓸 때, 해당 문자가 셀을 몇 칸 차지하는지 판단해야 합니다. 이 과정은 놀라울 정도로 복잡합니다! 자세한 내용은 Jeff Quast의 훌륭한 블로그 글을 참고하세요.

프로파일링을 통해 출력되는 모든 문자마다 걸리는 시간의 30%가 코드포인트 너비를 계산하는 데 쓰인다는 사실을 알아냈습니다. 그래서 이 부분을 최적화하기로 했고, 실제로 최적화에 성공했습니다.

매우 도움이 된 커뮤니티 멤버의 조언 덕분에, 저는 모든 유니코드 코드포인트 값에 대한 코드포인트 너비를 미리 계산해 두고, 메모리를 많이 차지하지 않으면서도 비교적 캐시 친화적인 3단계 룩업 테이블과 유사한 trie 구조를 사용해 코드포인트 너비를 빠르게 조회하도록 했습니다.

직접 커스텀 룩업 테이블을 구축함으로써 터미널 특유의 특수한 상황을 반영해 더욱 빠르게 만들 수 있었습니다. 예를 들어 이전 단계에서 제어 문자를 이미 걸러내므로 제어 문자가 등장할 수 없다는 것을 알고 있고, 터미널은 최대 이중폭 문자까지만 처리할 수 있으므로 삼중폭 문자(3-em 대시)는 런타임에 추가 조건문 없이 이중폭으로 고정할 수 있습니다. 모든 CPU 사이클이 중요합니다.

결과가 모든 것을 말해줍니다. 다시 한번 Apple Silicon M3 Max에서 ASCII 처리량은 2.8배 더 빨라졌습니다:

Benchmark 1: noop
  Time (mean ± σ):     113.2 ms ±   1.3 ms    [User: 88.2 ms, System: 23.5 ms]
  Range (min … max):   111.8 ms … 116.5 ms    25 runs

Benchmark 2: wcwidth
  Time (mean ± σ):     358.2 ms ±   2.5 ms    [User: 331.1 ms, System: 24.7 ms]
  Range (min … max):   355.2 ms … 362.3 ms    10 runs

Benchmark 3: ziglyph
  Time (mean ± σ):     549.1 ms ±   2.2 ms    [User: 505.1 ms, System: 31.8 ms]
  Range (min … max):   543.9 ms … 564.2 ms    10 runs

Benchmark 4: table
  Time (mean ± σ):     194.0 ms ±   1.4 ms    [User: 168.9 ms, System: 23.8 ms]
  Range (min … max):   192.6 ms … 197.5 ms    15 runs

그리고 균일하게 분포된 랜덤 UTF-8 처리량은 약 5배 더 빨라졌습니다:

Benchmark 1: noop
  Time (mean ± σ):     127.9 ms ±   1.5 ms    [User: 115.8 ms, System: 10.3 ms]
  Range (min … max):   126.5 ms … 131.4 ms    23 runs

Benchmark 2: wcwidth
  Time (mean ± σ):     136.5 ms ±   1.9 ms    [User: 124.3 ms, System: 10.4 ms]
  Range (min … max):   134.9 ms … 143.0 ms    21 runs

Benchmark 3: ziglyph
  Time (mean ± σ):     620.5 ms ±   1.3 ms    [User: 610.5 ms, System: 9.8 ms]
  Range (min … max):   619.0 ms … 602.6 ms    10 runs

Benchmark 4: table
  Time (mean ± σ):     122.4 ms ±   2.4 ms    [User: 110.5 ms, System: 10.4 ms]
  Range (min … max):   120.6 ms … 132.4 ms    23 runs

위 벤치마크에서 ziglyph는 코드포인트 너비 계산에 이전에 사용하던 API입니다. wcwidth 벤치마크는 코드포인트 너비를 위한 libc API이지만 잘못된 결과를 내놓습니다(이 주제에 대한 제 글 전체를 참고하세요). ASCII와 랜덤 UTF-8 시나리오 모두에서, 이제 우리는 단순하지만 잘못된 libc API보다 빠르게 정확한 결과를 얻습니다.4

실제 wall time 기준으로, 이 최적화 덕분에 ASCII를 cat하는 데 걸리는 시간은 추가로 2.5배, 일본어는 추가로 1.5배 빨라졌습니다. 앞서 다룬 최적화와 합치면, UTF-8의 복잡도에 따라 실제 일반 텍스트 처리량은 2배에서 5배까지 빨라졌습니다.


그래핌 분절 탐지 최적화 (#1494)

Ghostty는 그래핌을 인식하는 터미널 에뮬레이터입니다. 출력되는 모든 코드포인트에 대해 Ghostty는 해당 코드포인트가 새로운 그래핌의 일부인지 이전 그래핌의 일부인지 판단해야 합니다. 이 과정은 그래핌 분절 탐지(그래핌이 다른 그래핌으로 나뉘는 지점)로 알려져 있습니다.

예를 들어 “AB”에서 코드포인트 U+42 B는 코드포인트 U+41 A 옆에서 분절되어 두 개의 그래핌을 이룹니다. 그러나 “🧑‍🌾”는 세 개의 코드포인트 U+1F9D1 🧑, U+200D, U+1F33E 🌾로 이루어져 있습니다. 그래핌은 U+1F33E 🌾 이후까지 분절되지 않습니다.

그래핌 분절 탐지는 일반적으로 간단하지 않은 알고리즘입니다. 하지만 터미널이 보장할 수 있는 몇 가지 추가 전제 조건(제어 문자가 없다거나 ASCII만 있다는 등)을 더하면, 가능한 모든 코드포인트 쌍과 상태에 대한 룩업 테이블을 1KB 미만의 메모리로 계산할 수 있을 만큼 충분한 조건문을 제거할 수 있다는 사실을 깨달았습니다.

그래서 그대로 구현했고, 그 결과 그래핌 분절 탐지 속도는 거의 8배 빨라졌으며, 아무 것도 하지 않는 경우(바이트를 읽기만 하는 경우)보다 불과 27% 느릴 뿐입니다:

Benchmark 1: noop
  Time (mean ± σ):      51.2 ms ±   1.2 ms    [User: 43.6 ms, System: 6.7 ms]
  Range (min … max):    49.7 ms …  57.7 ms    55 runs

Benchmark 2: ziglyph
  Time (mean ± σ):     519.7 ms ±   0.8 ms    [User: 508.2 ms, System: 8.9 ms]
  Range (min … max):   518.4 ms … 520.7 ms    10 runs

Benchmark 3: table
  Time (mean ± σ):      65.3 ms ±   1.2 ms    [User: 57.2 ms, System: 6.9 ms]
  Range (min … max):    63.4 ms …  71.1 ms    44 runs

Summary
  'noop' ran
    1.27 ± 0.04 times faster than 'table'
   10.14 ± 0.25 times faster than 'ziglyph'

이는 앞서 언급한 최적화들과 결합되어 실제 환경에서도 매우 인상적인 결과를 만들어냈습니다.

아래 표에서는 굵게 표시된 터미널만 그래핌 클러스터링을 수행한다는 점에 유의하세요. 굵게 표시되지 않은 터미널은 그래핌 분절 탐지를 전혀 수행하지 않으므로, 거의 모든 경우에 Ghostty는 그래핌 분절 탐지를 수행하면서도 아무 것도 하지 않고 일반 텍스트를 읽는 터미널보다 빠르게 일반 텍스트를 읽습니다. Ghostty (legacy wcswidth) 항목은 그래핌 클러스터링을 비활성화한 상태의 Ghostty 속도입니다.

터미널time cat japanese-bible.txt (5.4MB)
Ghostty (old)189ms
Ghostty (new)73ms
Ghostty (legacy wcswidth)61ms
Alacritty 0.13.166ms (see note)
iTerm2 3.4.23470ms
Kitty (new unreleased SIMD branch5)103ms
Kitty 0.32.1392ms
Terminal.app macOS 14.3124ms
WezTerm 20240203-110809-5046fc22140ms

위 표에 대한 참고로, 파일을 10번 cat하여 평균을 냈습니다. 이상치는 전혀 없었으며, 모든 측정값의 범위는 항상 평균의 2% 이내였습니다.

위 테스트는 실제 환경에서 수행된 것이지만 매우 인위적이기도 합니다. Ghostty가 모든 시나리오에서 다른 모든 터미널보다 빠르다거나 하는 식의 포괄적인 주장을 하려는 것이 아닙니다. 위 데이터를 공유한 의도는 이번 개발 일지에서 논의한 최적화가 현실 세계에서 어떤 효과를 냈는지 보여주기 위함입니다.

아래 영상은 가장 인기 있는 macOS 터미널 중 하나와 Ghostty가 대용량 일본어 텍스트 파일을 읽는 속도 차이를 보여줍니다. 이 상황은 단순한 가정이 아닙니다. 시끄러운 로그를 tail로 지켜보거나 실수로 대용량 파일을 여는 경우 등에 실제로 적용될 수 있습니다.

영상 8초 부근에서 짧은 끊김이 보이는 것은 화면 녹화 소프트웨어로 인한 현상으로 보입니다. 실제로는 아무런 멈춤도 없었습니다.


CSI 시퀀스 고속 경로 최적화 (#1485)

앞서 언급한 모든 최적화는 일반 텍스트 출력에 초점이 맞춰져 있었습니다. Neovim이나 tmux처럼 제어 시퀀스가 많은 프로그램에는 큰 도움이 되지 않습니다. 이를 해결하기 위해 한 기여자CSI 시퀀스 고속 경로 파서를 구현했습니다.

CSI 시퀀스는 Neovim과 같은 프로그램에서 가장 흔히 사용되는 제어 시퀀스 중 하나입니다. 텍스트 스타일을 변경하거나 색상을 적용하고, 커서를 이리저리 이동시키며, 화면을 스크롤하는 등에 사용됩니다.

고속 경로는 스칼라(비 SIMD) 명령어로 구현되었으며, 주로 VT 파싱 상태 머신을 우회하고 유효한 CSI 구문(즉, 10진수로 인코딩된 숫자, 세미콜론, 단일 ASCII 종료 문자)을 낙관적으로 가정함으로써 동작합니다.

우리는 Neovim에서 작업을 수행하는 동안의 pty 스트림을 캡처해 가능한 한 빠르게 재생했고, 실제 환경에서 CSI 고속 경로가 (테스트한 워크로드에 따라) 처리량을 1.4배에서 2배까지 향상시켰음을 확인했습니다. CSI를 많이 사용하는 인기 스트레스 테스트인 DOOM fire 테스트에서는 FPS가 1.44배 증가했습니다.

이 특정 기여는 클로즈드 베타 기간 동안 별도의 기여자(@Qwerasd)가 전적으로 담당했다는 점을 다시 한번 강조하고 싶습니다. 그분은 이 글에서 다룬 다른 최적화들에 대해서도 피드백과 방향을 제공하며 큰 도움을 주셨습니다. 정말 감사드립니다!


마무리

서두에서 말씀드렸듯, 이번 개발 일지는 저와 커뮤니티가 Ghostty를 빠르게 만들기 위해 진행한 작업에 초점을 맞췄습니다. 이번에는 특히 IO를 빠르게 만드는 작업만을 다뤘습니다. 앞으로 개선하고 글로 다룰 다른 성능 지표(입력 지연, 시작 시간, 프레임레이트, 메모리 사용량 등)도 많이 남아 있습니다.

Ghostty의 IO 속도 개선은 아직 끝나지 않았습니다. 더 많은 병목 구간을 파악했고 해결책을 연구 중입니다. 향후 개발 일지에서 그 진행 상황을 다시 전해드릴 수 있기를 바랍니다.

속도 외에도 Ghostty는 지난 개발 일지 이후로 많은 개선이 이루어졌습니다. 다음 개발 일지에서는 그중 일부에 더 집중할 계획입니다. 누군가 최근 제가 진행 중인 장애 복구 작업을 발견했는데, 이에 대해서도 나중에 더 자세히 다뤄보고자 합니다.

마지막으로, 베타 참여 인원은 지난 개발 일지 당시 약 350명에서 이번 개발 일지 시점에는 650명 이상으로 늘어났습니다. 이슈를 제보하고 코드를 기여하며 서로에게 친절하고 도움이 되어 주신 모든 커뮤니티 여러분께 감사드립니다.

최신 소식을 받아보고 싶다면 트위터나 Mastodon에서 저를 팔로우해 주세요(하단의 링크 참고). 이 블로그는 RSS 피드도 제공합니다.

Boo. 👻

각주

  1. 터미널에는 텍스트 인코딩을 변경하는 메커니즘이 있으며 과거에는 기본값이 ASCII 전용이었지만, 대부분의 최신 터미널은 기본값으로 UTF-8을 사용합니다.

  2. 줄바꿈(0x0A), 캐리지 리턴(0x0D), 탭(0x09) 등도 제어 문자이지만, esc로 시작하는 제어 시퀀스에 비하면 그 수는 매우 적습니다.

  3. https://arxiv.org/pdf/2109.10433.pdf

  4. 해당 API가 실제로 “고장 난” 것은 아니며, 문서화된 대로 정확히 동작합니다. 다만 사람들이 보통 기대하는 너비가 아닐 뿐입니다.

  5. 2024년 2월 9일 기준 vt 브랜치를 사용했습니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글