Ghostty Devlog 006

Mitchell Hashimoto

Ghostty 개발 일지 006

안녕하세요! 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 명령어(x86))를 이용해 런타임에 올바른 버전을 선택해야 합니다.

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

Ghostty는 이제 위의 두 번째 코드 샘플과 거의 같은 방식으로 SIMD를 사용합니다. 더불어 UTF-8을 디코딩할 때도3 한 번에 여러 바이트를 처리하도록 SIMD를 활용합니다. 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의 경우) 파싱하는 속도가 단순히 데이터를 메모리에 읽어들이는 속도와 거의 동일하다는 것을 의미합니다.

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


코드포인트 너비에 대한 사전 계산된 조회 테이블 (#1486)

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

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

매우 도움이 되었던 한 커뮤니티 멤버의 제안 덕분에, 모든 유니코드 코드포인트 값에 대한 너비를 미리 계산해 두고, 메모리를 많이 차지하지 않으면서도 캐시 친화적인 트라이(trie) 형태의 3단계 조회 테이블을 이용해 코드포인트 너비를 빠르게 조회하도록 했습니다.

직접 만든 조회 테이블을 사용함으로써 터미널 고유의 특수한 상황을 반영해 더 빠르게 만들 수 있었습니다. 예를 들어 이 지점 이전에 제어 문자는 이미 걸러지므로 존재할 수 없다는 것을 알고 있고, 터미널이 최대 두 칸 너비 문자까지만 처리할 수 있다는 것을 알기 때문에 세 칸 너비 문자(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

실제 벽시계 시간 기준으로, 이 최적화로 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

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

위의 테스트는 실제 환경에서의 테스트이지만 매우 인위적인 상황이기도 합니다. Ghostty가 모든 시나리오에서 다른 모든 터미널보다 빠르다는 등의 포괄적인 주장을 하려는 것이 절대 아닙니다. 위 데이터를 공유하려는 의도는 이번 개발 일지에서 다룬 최적화가 실제 환경에서 어떤 효과를 내는지 보여드리기 위함입니다.

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

영상에서 8초 부근에 잠깐 끊기는 현상이 보이는 점에 유의해 주세요. 이는 화면 녹화 소프트웨어로 인한 아티팩트로 보이며, 실제로는 전혀 끊김을 느끼지 못했습니다.


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

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

CSI 시퀀스는 Neovim 같은 프로그램에서 사용되는 가장 일반적인 제어 시퀀스 중 하나입니다. 텍스트 스타일 변경, 색상 적용, 커서 이동, 화면 스크롤 등에 사용됩니다.

고속 경로는 스칼라(non-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명 이상으로 늘어났습니다. 이슈를 제보하고 코드를 기여하며 서로에게 친절하고 도움이 되어 주신 모든 커뮤니티 여러분께 감사드립니다.

최신 소식을 계속 받아보고 싶으시다면 Twitter나 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 브랜치를 사용했습니다.

원문은 Mitchell Hashimoto님이 에 게재했습니다.

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