<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://blog.untyped.kr/feed.xml" rel="self" type="application/atom+xml" /><link href="https://blog.untyped.kr/" rel="alternate" type="text/html" /><updated>2026-06-23T23:27:45+00:00</updated><id>https://blog.untyped.kr/feed.xml</id><title type="html">untyped</title><author><name>sangwoo-joh</name></author><entry><title type="html">도메인 구매</title><link href="https://blog.untyped.kr/domain-migration" rel="alternate" type="text/html" title="도메인 구매" /><published>2026-06-23T00:00:00+00:00</published><updated>2026-06-23T00:00:00+00:00</updated><id>https://blog.untyped.kr/domain-migration</id><content type="html" xml:base="https://blog.untyped.kr/domain-migration"><![CDATA[<p>블로그를 시작한 지가 10년이 다 되어 가는데 드디어 제대로 된 도메인을 샀다. 사실 블로그 초기에 요상한 이름으로 닷넷 도메인을 구매한 적이 있긴 했는데, 의외로 내가 이름에 큰 애착을 갖는다는 사실을 깨달았고, 그 요상한 이름이 썩 마음에 들지 않아 결국은 흐지부지된 적이 있다. 그 후로는 그냥 적당한 이름이 떠오르지 않아서 영어 본명을 썼고, 그때나 지금이나 엄청난 청중을 염두에 둔 것은 아니라 적당히 무료 호스팅인 깃허브 페이지를 이용해 왔다. 그러다가 <a href="https://en.wiktionary.org/wiki/yak_shaving">야크 쉐이빙</a>이라는 컨셉이 OCaml을 비롯해 중구난방한 내 블로그 주제와 맞는 것 같아 Caml Shaving 이라는 이름을 좀 썼다가, 다시 마땅한 이름이 떠오르지 않아 Untitled를 임시로 달아뒀다가, 작년부터는 불현듯 untyped라는 키워드가 마음에 들어 이 이름을 유지하고 있다. 강한 타입 시스템을 가진 OCaml로 작성한 코드가 타입 체커를 통과하고 나면 타입이 없는 (untyped) 람다 표현식이 되어 그 위에서 최적화가 진행되는데, 마치 여러 글감을 카테고리에 맞게 기록해뒀다가 컴파일하듯 글을 쓰고 나면 최종적으로는 내가 의도 했던 내용에 최적화된 글이 나오는 것과 유사하다고 생각해서 이런 이름을 지은 것은 아니고, 그냥 끼워 맞춰 보았다.</p>

<p>아무튼 마음에 드는 이름은 정했으니 적절한 최상위 도메인만 고르면 되는 거였는데, 여기에 또 한참을 고민을 했다.</p>
<ul>
  <li>당연하게도 닷컴, 닷넷, <code class="language-plaintext highlighter-rouge">.dev</code>, <code class="language-plaintext highlighter-rouge">.io</code>, <code class="language-plaintext highlighter-rouge">.ai</code> 같은 유명한 애들은 이미 다 점령 당한 상태였다. 나도 닷넷 갖고 싶었는데 말이지.</li>
  <li><code class="language-plaintext highlighter-rouge">.info</code>도 고민했는데, 잡설이 가득한 블로그에는 맞지 않겠다 싶어서 스킵.</li>
  <li><code class="language-plaintext highlighter-rouge">.work</code>도 고민했는데, 왠지 이 도메인을 사면 포트폴리오를 보여야 할 것만 같은 압박이 느껴져서 스킵.</li>
  <li><code class="language-plaintext highlighter-rouge">.ml</code>이 OCaml의 확장자이기도 하고 또 요즘 먹고 살기 위해 열심히 하고 있는 머신러닝과도 맞아서 꽤 고민했는데, <a href="https://en.wikipedia.org/wiki/.ml">여기에 얽힌 흑역사</a>를 보니, 23년부터 정상화를 노력하고는 있다지만 아직까지는 불안정해 보여서 스킵.</li>
  <li><code class="language-plaintext highlighter-rouge">.sh</code>이 쉘 스크립트 확장자이기도 하고 brew.sh를 비롯한 각종 힙한 프로덕트에 .sh 도메인을 쓰는 곳이 많아서 끝까지 고민을 했는데, 취미로만 유지하기에는 꽤 부담되는 가격인데다 너무 힙해서 (…) 오히려 반감이 들어 스킵. 비슷한 이유로 <code class="language-plaintext highlighter-rouge">.run</code>도 탈락.</li>
</ul>

<p>고민 끝에 근본의 kr 도메인을 구입했다. 어차피 전부 한글로 작성한 글 밖에 없기도 하고 가격도 착해서 딱히 마다할 이유가 없었다. 도메인을 구매한 김에 호스팅도 깃허브 페이지에서 클라우드플레어 페이지로 옮기고 DNS도 클라우드플레어로 관리하도록 옮겼다. 기분 탓인지 깃허브 액션보다 클라우드플레어 페이지 빌드 속도가 훨씬 빠르다. 구글 애널리틱스랑 Giscus는 딱히 수정하지 않아도 돼서 편했다. 약간의 설정과 기존 글에 걸린 링크들만 수정하고 나니 모든 준비가 완료되었다. 이제 클라우드플레어와 구글 서치 콘솔이 착실하게 내 도메인과 사이트와 캐시를 전 세계에 퍼뜨려주기만을 기다리면 된다. 기존의 깃허브 페이지 도메인은 404와 함께 새 도메인으로 리다이렉트해두었으니 길을 잃지는 않을 것이다. 한 가지 걱정은 (의외의) RSS 구독자 n명(\(n \ge 3\))에 대한 것인데, 리다이렉트 페이지와 함께 피드 알림 xml을 같이 마련해두었으니 역시 길을 잃지 않길 바랄 뿐이다.</p>

<p>사실 하는 김에 깃허브 프로필 이름까지 바꿀까도 고민했는데, 의외로 실명이 글을 쓰는 태도에 영향을 미치고 있어서 이건 앞으로도 그냥 냅둘 생각이다.</p>]]></content><author><name>sangwoo-joh</name></author><category term="essay" /><category term="life" /><category term="other" /><summary type="html"><![CDATA[블로그를 시작한 지가 10년이 다 되어 가는데 드디어 제대로 된 도메인을 샀다. 사실 블로그 초기에 요상한 이름으로 닷넷 도메인을 구매한 적이 있긴 했는데, 의외로 내가 이름에 큰 애착을 갖는다는 사실을 깨달았고, 그 요상한 이름이 썩 마음에 들지 않아 결국은 흐지부지된 적이 있다. 그 후로는 그냥 적당한 이름이 떠오르지 않아서 영어 본명을 썼고, 그때나 지금이나 엄청난 청중을 염두에 둔 것은 아니라 적당히 무료 호스팅인 깃허브 페이지를 이용해 왔다. 그러다가 야크 쉐이빙이라는 컨셉이 OCaml을 비롯해 중구난방한 내 블로그 주제와 맞는 것 같아 Caml Shaving 이라는 이름을 좀 썼다가, 다시 마땅한 이름이 떠오르지 않아 Untitled를 임시로 달아뒀다가, 작년부터는 불현듯 untyped라는 키워드가 마음에 들어 이 이름을 유지하고 있다. 강한 타입 시스템을 가진 OCaml로 작성한 코드가 타입 체커를 통과하고 나면 타입이 없는 (untyped) 람다 표현식이 되어 그 위에서 최적화가 진행되는데, 마치 여러 글감을 카테고리에 맞게 기록해뒀다가 컴파일하듯 글을 쓰고 나면 최종적으로는 내가 의도 했던 내용에 최적화된 글이 나오는 것과 유사하다고 생각해서 이런 이름을 지은 것은 아니고, 그냥 끼워 맞춰 보았다.]]></summary></entry><entry><title type="html">근황</title><link href="https://blog.untyped.kr/2026-half-musing" rel="alternate" type="text/html" title="근황" /><published>2026-06-19T00:00:00+00:00</published><updated>2026-06-19T00:00:00+00:00</updated><id>https://blog.untyped.kr/2026-half-musing</id><content type="html" xml:base="https://blog.untyped.kr/2026-half-musing"><![CDATA[<h2 id="이직">이직</h2>
<p>요즘 글을 못 쓴 이유가 있었다. 평소라면 하지 않았을 큰 결정을 했고, 그에 따른 변화가 있었고, 적응하느라 정신이 없다. 한마디로 바빴다….</p>

<p>하루아침에 이런 결심을 한 건 아니다. 꾸준히 여러가지를 해보고 있었고, 종종 시도도 했었다. 몇 번의 크고 작은 기회도 있었다. 하지만 그때마다 다양한 핑계거리를 찾았다. 가족들, 친구들, 지금 회사에서의 평가, 익숙한 프로젝트, 한국에서의 안정된 생활 등, 가지 않을 이유를 찾자면 끝도 없었다. 그렇게 꽤 시간이 지나버렸고, 문득 도전할 수 있는 날이 그렇게 많이 남지 않았다는 걸 깨달았다. 인생이 꽤나 짧다는 걸 깨닫게 해준 여러 사건들도 있었다. 할 수 있을 때 해보고 싶었다.</p>

<p>물론 운도 따라줬다. 요즘 같은 시기에 하고 싶다는 것만으로는 부족하다는 걸 안다. 결정을 하고 보니 내 주변에 좋은 사람들이 정말 많았고, 나도 그동안 나쁘지 않게 열심히 해왔다는 걸 깨닫기도 했다. 정말로 감사할 따름이다.</p>

<h2 id="불확실">불확실</h2>
<p>그렇게 큰 결심을 하고 옮겼지만 …어휴… 내 그릇의 크기를 여실히 깨닫기도 했다.</p>

<p>일단 내 선택의 영향이 더 이상 나 개인에서 끝나지 않게 되었다. 나의 선택으로 가족 전체의 미래가 불확실, 아니지 정확히는 불확실할지도 모르는 상황이 되니까, 돌이킬 수 없는 선택에 대한 후회와 스트레스가 몰려왔다. 게임처럼 세이브 &amp; 로드가 불가능하다는 사실 만으로도 나에겐 꽤 큰 스트레스였다. ‘왜 내가 굳이 이런 선택을 했을까’, ‘그냥 익숙한 환경에서 편하게 살 수 있었는데 내가 잘못된 선택을 한 거 아닐까’, 등등, 다양한 부정적인 생각들이 나를 갉아먹었다. 사실 아직 일어나지도 않은 일인데.</p>

<p>그러다가 이런 글을 봤다.</p>

<blockquote>
  <p>극심한 스트레스 상황에 처하게 되면 심리 면역체계는 분주히 움직여서 <strong>우리가 기대하는 이상으로 스스로 그 상황을 극복할 수 있는 힘을 준다</strong>. 그러나 스트레스 상황에 처하지 않은 현 시점에서 미래의 스트레스 상황을 상상만 할 때는, 그런 면역체계가 작동할 것이라는 사실을 미처 고려하지 못한다. 그래서 <strong>우리는 부정적인 사건의 충격을 과대하게 예측한다</strong>. … 심리 면역체계의 이런 탁월한 활동으로 인해 우리는 실연이라는 역경으로부터 예상 외로 빨리 벗어나게 된다. - <프레임>, 최인철</프레임></p>
</blockquote>

<p>이 말대로, 아직 모르는 미래의 상황을 생각할 때는 그 충격과 영향을 과하게 평가하게 된다는 걸 체감했다. 정말 수렁으로 빠지는 느낌이 들었다. 아무튼 시간은 흘러가고, 배는 고프고, 아이는 자란다. 불확실에 떨면서 정신을 갉아먹느니 현재를 성실하게 보내는 게 낫다. “하루하루는 성실하게, 인생 전체는 되는대로”, 이거 정말 간단하지만 지키기 어려운 말이구만. 아무튼 할 수 있는 걸 하기로 했다.</p>

<p>그래서 “지금”에 집중해보려고 했다. 폰을 내려놓고, 가든에 의자 펴고 앉아서, 바람에 흩날리는 나무와 하늘을 보면서, 커피 한잔 하는 걸 즐기게 되었다. 덕분에 아들이랑 밖에 앉아서 도란도란 이야기하는 재미도 있고, 여러모로 머릿 속이 정리되어 좋다. 게다가 내 선택을 응원해주는 가족들이 있다. 그것만으로도 불확실함을 해쳐나갈 위로가 된다. 그리고 과거에 당시에는 정말 최악의 상황이라고 생각했지만, 돌이켜보니 그래도 어찌저찌 잘 해쳐나갔던 기억도 잔뜩 있었다. 정신만 차리면 어떻게든 되겠지.</p>

<h2 id="그냥-하기">그냥 하기</h2>
<p>다행히 걱정하던 일은 일어나지 않았다. 내가 뭘 한 건 아니고 그냥 운이 좋았다. 그럼 이제부터 불행 끝 행복 시작인가? 또 그렇지는 않을 거다. 언제나 인생은 문제 투성이일테고, 단지 그 중에서 어떤 문제를 선택할지의 문제다. 그리고 내가 풀기로 한 문제를 풀 뿐이다.</p>

<p>그런데 요즘 이게 정말 어려워졌다. 예전에도 오래 몰입하기 쉬운 편은 아니었는데, 이제는 정말 집중하기가 어렵다. 일단 손가락 하나 까딱 하면 쉽게 채워지는 도파민 덕분에 점차 자극에 무뎌지고 있다. 아이러니하게도 이제는 자사 제품이 된 릴스를 멀리 하려고 시야에서 폰을 치워버려야 그나마 뭔갈 시작해 볼 수 있다. 게다가 자꾸만 지능을 상품으로 만들어 화이트칼라의 명줄을 끊으려는 악명높은 두 CEO (이제는 셋인가?) 탓에 ‘이걸 해봐야 뭐하나…’ 하는 무력감과 회의감이 발목을 잡는 것도 영향이 크다. 게다가 거시적인 정치/경제/기후변화 등, 쏟아지는 정보에 주의를 빼앗기기 일쑤였다.</p>

<p>그럼에도 불구하고 그냥 해야 한다는 걸 느꼈다. 일단 해서 빨리 실패를 해야, 다음 번에 뭘 할지 감이 잡힌다. 도파민에 대한 유혹이나 회의감이 스멀스멀 찾아오기 전에 그냥 빠르게 해야 한다.</p>

<h2 id="싫어도-하기">싫어도 하기</h2>
<p>게다가 거의 10년을 몸담고 있던 분야에서 벗어나 새로운 도메인에 적응하는 일은 꽤나 쉽지 않다. 멘탈 모델도 새로 쌓아야 하고 안그래도 전부 영어인데 모르는 단어나 줄임말들이 너무 많아서 한 문단을 세 번은 읽어야 겨우 기억에 남는다. 머릿 속에 큰 그림도 여러번 그렸다가 지웠다가 한다. 새카맣고 거대한 숲에서 작은 랜턴 하나만 들고 길을 찾는 기분이다. 막힐 때마다 싫은 기분이 들지만, 그래도 한다. 다행히도 복잡하고 거대한 시스템을 이해하고 그 위에서 내가 할 수 있는 걸 기여하는 일은 여전히 재밌다. 즐길 틈새를 찾아 파고들면 어느새 싫은 기분도 사라진다.</p>

<p>그 외에도 많은 것을 내려놓고 있다. 일단 잘 하려는 욕심을 버리고 있다. 익숙한 분야라면 오랜 시간 쌓아둔 거대한 암묵지가 새로운 뾰족한 부분을 이해하는데 큰 도움을 줄 테지만, 새로운 도메인에서는 그런 게 많지 않다. 이미 낡아버린 문서를 싫어도 읽어야 하고, 바보같은 질문도 주변에 마구 던지고, AI의 할루시네이션에 속아넘어가기도 하고, 맞지 않은 그림도 여러 번 그려가며 좌충우돌 해야 겨우 올바른 길을 찾는다. 중요한 것은 자존심을 내려놓고 싫어도 하는 것이다. 잘 할 필요도 없다. 그냥 한다. 생각해보면 내 인생도 그래 왔다. 딱히 엄청나게 잘 한 것도 없이 그냥 하다보니까 여기까지 왔다. 그냥 이렇게 하다 보면 언젠가는 된다. 이번에도 그럴 것이다.</p>

<h2 id="재미-열정-취미">재미, 열정, 취미</h2>
<p>여러가지에서 벗어나 새로운 시작을 해보니, 생각보다 내가 재밌게 즐기는 거리가 많지 않다는 걸 깨달았다. 운동도 그다지 즐기지 않고, 영국에 와서 호기롭게 골프도 시작해봤지만 내 마음대로 안되는 몸뚱아리에 답답함만 늘어갔다. 그래도 종종 하고는 있지만. 요리는 좋아하는 메뉴만 가끔씩 하는 편이니 즐긴다고 하긴 어렵다. 한때는 기타를 잡아봤고 심지어 어릴 때는 부모님의 성화로 피아노, 바이올린, 단소 등 다양한 것을 해봤지만 지금까지 즐기는 건 딱히 없다. 그나마 한국에 있을 땐 노래방이라도 종종 갔는데. 학생 때는 미술을 제일 좋아하고 즐겼지만 지금 그리는 그림은 기껏해야 설계를 위한 다이어그램이 전부다. 아들 사진을 열심히 찍어서 SNS에 종종 올리곤 하지만, 사진 찍는 것을 즐긴다고 보긴 힘들고.</p>

<p>작년까지만 해도 그나마 취미 코딩을 하곤 했는데, AI가 프로그래밍의 가장 재밌는 부분을 느슨한 정확도로 상당히 잘 해내어 버리게 되면서 뭔가 열정을 빼앗긴 기분이 든다. 여전히 복잡한 실제 서비스를 설계하고 이해하는 일은 재밌지만, 이걸 취미로 삼기에는 산출물(?)이 애매하다.</p>

<p>생각보다 인생이 길게 남았는데, 내가 열정적으로 즐기는 게 생각보다 없다는 사실에 공허하기도 하고 아쉽기도 하다. 사실 이래도 괜찮다고 생각은 하지만, 주어진 시간이 아깝기도 하고 무엇보다 주변 친구들이 뭔가에 열정적으로 푹 빠져 있는 모습이 부러우면서도 궁금했다. 그래서 지금이라도 이것저것 해보고 있다.</p>

<p>일단은 도메인을 넓히는 것부터 시작해보고 있다. 평소라면 절대 안읽을 책도 읽고 있고, 학부 필수 과목이었지만 제대로 깊게 공부하진 못해 늘 아쉬움이 남았던 통계학 책도 다시 펼쳐보고 있다. 스도쿠 같은 퍼즐을 풀어보다가 어쩌다보니 지금은 체스 퍼즐을 시간 날 때 풀어보고 있는데 이것도 새로운 재미다. 취미 코딩은 AI 없이 유기농으로 시도해볼지, 아니면 AI를 전면적으로 등에 업고 (업힌다고 해야할지..) 시도를 해볼지 고민 중인데, 회사에서 써보니 이게 제대로 끝까지 써보려면 한두푼 드는 것이 아니라 아무래도 유기농 쪽으로 마음이 기울고 있다.</p>

<p>그나마 글을 쓰는 취미는 아주 느슨하게 유지되고 있는 것 같긴 하다. 이것도 AI한테 열정을 빼앗겨 버린 부분이 상당하지만, 그럼에도 유기농의 매력은 있다고 믿는다. 무엇보다 이 블로그는 공개된 잡설+일기에 가깝고 MAU(라고 부르기도 민망한..)도 하찮아서 사실상 나만 보는 공간에 가까우니, 내 글의 독자는 미래의 나라는 생각으로 쓴다. 아마 앞으로도 온갖 이상한 주제의 글을 쓸 것이다.</p>]]></content><author><name>sangwoo-joh</name></author><category term="musing" /><summary type="html"><![CDATA[이직 요즘 글을 못 쓴 이유가 있었다. 평소라면 하지 않았을 큰 결정을 했고, 그에 따른 변화가 있었고, 적응하느라 정신이 없다. 한마디로 바빴다….]]></summary></entry><entry><title type="html">1월의 잡상</title><link href="https://blog.untyped.kr/january-musings" rel="alternate" type="text/html" title="1월의 잡상" /><published>2026-01-30T00:00:00+00:00</published><updated>2026-01-30T00:00:00+00:00</updated><id>https://blog.untyped.kr/january-musings</id><content type="html" xml:base="https://blog.untyped.kr/january-musings"><![CDATA[<ol>
  <li>문득 정신을 차리고 보니 LLM(+멀티모달) 발전 속도가 생각한 것보다 너무 빠르다. 2022년 쯤에 챗지피티가 처음 세상에 공개되었을 때만 해도 글쓰기나 코딩이나 그다지 인상깊은 성능은 아니었는데, 그로부터 4년 정도 지난 지금 내 일상에서는 제미나이를 비롯한 LLM(+Reasoning, RAG)들의 도움을 엄청나게 받고 있다. 이게 그냥 코딩 작업을 대신 해준다 이런 수준이 아니라, 법률 문서나 계약서를 검토해주거나, 이메일 톤을 다듬고 프로페셔널하게 정리해주거나, 잘 모르는 토픽에 대해서 토론하고 더 깊이 이해할 수 있는 씨드 페이퍼를 추천받거나, 어떤 목적을 위해서 지금 필요한 일이 뭔지 계획을 세우거나 등등. 일상의 꽤 많은 부분들에 증폭제로 작용하고 있다. 그저 “다음 토큰 예측하는 행렬 연산”일 뿐이라고 생각했는데, 그게 아니라 진짜로 이게 “지능”의 단초일 지도 모르겠다.</li>
  <li>많은 것이 양극화다. 한쪽에서는 LLM을 기반으로 AI가 엄청나게 발달하고 있어서 AGI를 넘어 ASI일지도 모른다는, 약간의 호들갑으로까지 보일 정도의 현상을 느끼고 있다는 무리가 있다. 반면 다른 쪽에서는 이제 막 AI를 도입해야 한다, 아니면 우리도 AI 개발해야 한다, 아니면 AI를 막아야 한다(?) 같은 논쟁이 벌어지고 있으니, 물론 정답은 없겠지만 참 웃기면서 서글프다.</li>
  <li>기계로 만든 지능이 모든 인간 지능의 합계보다 뛰어나 스스로 발전하기 시작하는 특이점이 오면 대체 어떻게 되려나? 일론 머스크 말대로 화폐 가치가 의미 없어지는 날이 올까? 그러기엔 머스크는 조만장자인데 말이지… XX에 관심 없다는 사람이 XX에 제일 미친 사람 아니던가.</li>
  <li>정적 분석에서 열심히 분석하다가 모르겠으면 그냥 $\top$으로 Overapproximate, Widening 해서 정확도를 잃어버리는 대신 Fixed Point에 도달하곤 하는데, 요즘 AI로 인한 미지의 두려움 때문에 사람들이 비관적인 결론에 너무 빨리 도달하는 게 아닌가 하는 생각이 든다. AI가 모든 인간을 대체해서 일자리를 다 없애버리고 인간성을 종말시키고 세상을 멸망시킬거라는 류의 이야기들. 나중에는 결국 정적 분석에서도 Narrowing 해서 정확도 찾으려는 노력을 하는데 말이지… 현실에서도 이런 노력이 보이면 좋겠는데, 비관론이 너무 자극적이고 잘 팔리는 소재라서 그런지 다른 이야기가 잘 안보여서 아쉽다.</li>
  <li>그래서 인공지능이 인간보다 훨씬 똑똑해져서 인간이 더이상 생각할 필요가 없어지면, 지금이랑 많이 다를까? 사실 지금도 수많은 책, 비디오, 구글 검색 결과, 논문 등 웬만한 훌륭한 지식은 도처에 널려있다. 인공지능은 이걸 압축해놓은 실행 파일 같은거라서 상호작용이 가능하다는 점이 다르긴 한데, 인류가 이런 식의 도구를 지녀본 역사가 없다보니 무지로 인한 두려움이 더 큰 것 같기도 하고. 아직까지는 행위는 사람이 해야 하니 이걸 다행이라고 생각해야 할지. 정말 두려운 건 인공지능이 “사람보다 똑똑하다”는 사실보다는 계속 발전하다가 결국 언젠가는 “자율성(Agency)”을 갖게 될 거라는 사실 아닐까.</li>
  <li>플라스크 속 작은 인간이 현실에 탄생한 것 같다. 호문쿨루스는 플라스크 밖으로 나가면 살 수 없다고 하는데, 마찬가지로 지금의 인공지능도 GPU 클라우드를 벗어날 수 없지 않나. 옛날에 덴마에도 비슷한 에피소드가 있었는데, 고드가 네트워크 더미에 자의식을 흩뿌려서 신과도 같은 권능을 얻었던가… 인공지능이 자율성이든 의식이든 뭐든 간에 그런 걸 갖게 되면 어떤 방식으로 이산적인 인프라에서 자신의 연속적인 영속성을 유지할 수 있을까? 고드의 경우에는 트라우마와도 같은 “살인”이라는 키워드가 네트워크에 끊임없이 검색되는 바람에 자의식을 잃지 않을 수 있었고, 게다가 나노입자로 된 육신까지 얻었었는데, 얼마전에 또 아틀라스가 발표되었고… 현실이 SF를 따라가는구나.</li>
  <li>지금의 많은 시스템이 계속 존재할 수 있을까? 금융 시스템, 민주주의, 사회주의, 정치, 종교 같은 것들… 인류 최고의 지성조차 더 이상 이해할 수 없는 초지능을 월 $20으로 구독하고 인간의 몇 배나 되는 근력을 가진 기계몸을 연 1천만원으로 구매해서 모두가 다 이븐하게 누릴 수 있게 되면, 결국 돈은 호문쿨루스를 만든 빅테크(그게 어느 회사가 됐건간에)들이 다 빨아들이고 모든 화이트칼라와 블루칼라 일자리가 사라지고나면, 사람이 해야 하는 일은 대체 뭘까? 요즘 종종 보이는 그 옛날 로마 시대의 기본소득 이야기가 그래서 회자되는 것인가.</li>
  <li>세상의 모든 문제가 초지능만으로 풀리지는 않을 것 같은데, 물리적인 병목이 발목을 잡을 것 같기도 하고…</li>
</ol>

<p>결국은 인간에게 필요한 의식주가 남는건가. 역시 답은 치킨집인가?</p>]]></content><author><name>sangwoo-joh</name></author><category term="musing" /><summary type="html"><![CDATA[문득 정신을 차리고 보니 LLM(+멀티모달) 발전 속도가 생각한 것보다 너무 빠르다. 2022년 쯤에 챗지피티가 처음 세상에 공개되었을 때만 해도 글쓰기나 코딩이나 그다지 인상깊은 성능은 아니었는데, 그로부터 4년 정도 지난 지금 내 일상에서는 제미나이를 비롯한 LLM(+Reasoning, RAG)들의 도움을 엄청나게 받고 있다. 이게 그냥 코딩 작업을 대신 해준다 이런 수준이 아니라, 법률 문서나 계약서를 검토해주거나, 이메일 톤을 다듬고 프로페셔널하게 정리해주거나, 잘 모르는 토픽에 대해서 토론하고 더 깊이 이해할 수 있는 씨드 페이퍼를 추천받거나, 어떤 목적을 위해서 지금 필요한 일이 뭔지 계획을 세우거나 등등. 일상의 꽤 많은 부분들에 증폭제로 작용하고 있다. 그저 “다음 토큰 예측하는 행렬 연산”일 뿐이라고 생각했는데, 그게 아니라 진짜로 이게 “지능”의 단초일 지도 모르겠다. 많은 것이 양극화다. 한쪽에서는 LLM을 기반으로 AI가 엄청나게 발달하고 있어서 AGI를 넘어 ASI일지도 모른다는, 약간의 호들갑으로까지 보일 정도의 현상을 느끼고 있다는 무리가 있다. 반면 다른 쪽에서는 이제 막 AI를 도입해야 한다, 아니면 우리도 AI 개발해야 한다, 아니면 AI를 막아야 한다(?) 같은 논쟁이 벌어지고 있으니, 물론 정답은 없겠지만 참 웃기면서 서글프다. 기계로 만든 지능이 모든 인간 지능의 합계보다 뛰어나 스스로 발전하기 시작하는 특이점이 오면 대체 어떻게 되려나? 일론 머스크 말대로 화폐 가치가 의미 없어지는 날이 올까? 그러기엔 머스크는 조만장자인데 말이지… XX에 관심 없다는 사람이 XX에 제일 미친 사람 아니던가. 정적 분석에서 열심히 분석하다가 모르겠으면 그냥 $\top$으로 Overapproximate, Widening 해서 정확도를 잃어버리는 대신 Fixed Point에 도달하곤 하는데, 요즘 AI로 인한 미지의 두려움 때문에 사람들이 비관적인 결론에 너무 빨리 도달하는 게 아닌가 하는 생각이 든다. AI가 모든 인간을 대체해서 일자리를 다 없애버리고 인간성을 종말시키고 세상을 멸망시킬거라는 류의 이야기들. 나중에는 결국 정적 분석에서도 Narrowing 해서 정확도 찾으려는 노력을 하는데 말이지… 현실에서도 이런 노력이 보이면 좋겠는데, 비관론이 너무 자극적이고 잘 팔리는 소재라서 그런지 다른 이야기가 잘 안보여서 아쉽다. 그래서 인공지능이 인간보다 훨씬 똑똑해져서 인간이 더이상 생각할 필요가 없어지면, 지금이랑 많이 다를까? 사실 지금도 수많은 책, 비디오, 구글 검색 결과, 논문 등 웬만한 훌륭한 지식은 도처에 널려있다. 인공지능은 이걸 압축해놓은 실행 파일 같은거라서 상호작용이 가능하다는 점이 다르긴 한데, 인류가 이런 식의 도구를 지녀본 역사가 없다보니 무지로 인한 두려움이 더 큰 것 같기도 하고. 아직까지는 행위는 사람이 해야 하니 이걸 다행이라고 생각해야 할지. 정말 두려운 건 인공지능이 “사람보다 똑똑하다”는 사실보다는 계속 발전하다가 결국 언젠가는 “자율성(Agency)”을 갖게 될 거라는 사실 아닐까. 플라스크 속 작은 인간이 현실에 탄생한 것 같다. 호문쿨루스는 플라스크 밖으로 나가면 살 수 없다고 하는데, 마찬가지로 지금의 인공지능도 GPU 클라우드를 벗어날 수 없지 않나. 옛날에 덴마에도 비슷한 에피소드가 있었는데, 고드가 네트워크 더미에 자의식을 흩뿌려서 신과도 같은 권능을 얻었던가… 인공지능이 자율성이든 의식이든 뭐든 간에 그런 걸 갖게 되면 어떤 방식으로 이산적인 인프라에서 자신의 연속적인 영속성을 유지할 수 있을까? 고드의 경우에는 트라우마와도 같은 “살인”이라는 키워드가 네트워크에 끊임없이 검색되는 바람에 자의식을 잃지 않을 수 있었고, 게다가 나노입자로 된 육신까지 얻었었는데, 얼마전에 또 아틀라스가 발표되었고… 현실이 SF를 따라가는구나. 지금의 많은 시스템이 계속 존재할 수 있을까? 금융 시스템, 민주주의, 사회주의, 정치, 종교 같은 것들… 인류 최고의 지성조차 더 이상 이해할 수 없는 초지능을 월 $20으로 구독하고 인간의 몇 배나 되는 근력을 가진 기계몸을 연 1천만원으로 구매해서 모두가 다 이븐하게 누릴 수 있게 되면, 결국 돈은 호문쿨루스를 만든 빅테크(그게 어느 회사가 됐건간에)들이 다 빨아들이고 모든 화이트칼라와 블루칼라 일자리가 사라지고나면, 사람이 해야 하는 일은 대체 뭘까? 요즘 종종 보이는 그 옛날 로마 시대의 기본소득 이야기가 그래서 회자되는 것인가. 세상의 모든 문제가 초지능만으로 풀리지는 않을 것 같은데, 물리적인 병목이 발목을 잡을 것 같기도 하고…]]></summary></entry><entry><title type="html">가비지 컬렉션 이야기</title><link href="https://blog.untyped.kr/garbage-collection" rel="alternate" type="text/html" title="가비지 컬렉션 이야기" /><published>2026-01-21T00:00:00+00:00</published><updated>2026-01-21T00:00:00+00:00</updated><id>https://blog.untyped.kr/garbage-collection</id><content type="html" xml:base="https://blog.untyped.kr/garbage-collection"><![CDATA[<h2 class="no_toc" id="목차">목차</h2>

<ul id="markdown-toc">
  <li><a href="#왜-필요할까" id="markdown-toc-왜-필요할까">왜 필요할까?</a>    <ul>
      <li><a href="#역사가-말해줌" id="markdown-toc-역사가-말해줌">역사가 말해줌</a></li>
      <li><a href="#문제의-난이도" id="markdown-toc-문제의-난이도">문제의 난이도</a></li>
      <li><a href="#인지적-부하" id="markdown-toc-인지적-부하">인지적 부하</a></li>
    </ul>
  </li>
  <li><a href="#추상화" id="markdown-toc-추상화">추상화</a>    <ul>
      <li><a href="#루트-집합-root-set" id="markdown-toc-루트-집합-root-set">루트 집합 (Root Set)</a></li>
      <li><a href="#컬렉터와-뮤테이터" id="markdown-toc-컬렉터와-뮤테이터">컬렉터와 뮤테이터</a></li>
      <li><a href="#박싱-boxing" id="markdown-toc-박싱-boxing">박싱 (Boxing)</a></li>
    </ul>
  </li>
  <li><a href="#레퍼런스-카운팅" id="markdown-toc-레퍼런스-카운팅">레퍼런스 카운팅</a>    <ul>
      <li><a href="#정확성의-한계" id="markdown-toc-정확성의-한계">정확성의 한계</a></li>
      <li><a href="#성능-오버헤드" id="markdown-toc-성능-오버헤드">성능 오버헤드</a></li>
      <li><a href="#모든-작업이-암묵적인-쓰기-연산" id="markdown-toc-모든-작업이-암묵적인-쓰기-연산">모든 작업이 암묵적인 쓰기 연산</a></li>
      <li><a href="#레퍼런스-카운팅의-현재" id="markdown-toc-레퍼런스-카운팅의-현재">레퍼런스 카운팅의 현재</a></li>
    </ul>
  </li>
  <li><a href="#트레이싱-컬렉션" id="markdown-toc-트레이싱-컬렉션">트레이싱 컬렉션</a>    <ul>
      <li><a href="#마크-스윕-컬렉션-mark--sweep-collection" id="markdown-toc-마크-스윕-컬렉션-mark--sweep-collection">마크 스윕 컬렉션 (Mark &amp; Sweep Collection)</a>        <ul>
          <li><a href="#프리-리스트-free-list" id="markdown-toc-프리-리스트-free-list">프리 리스트 (Free List)</a></li>
          <li><a href="#삼색-조합-tri-colour-scheme" id="markdown-toc-삼색-조합-tri-colour-scheme">삼색 조합 (Tri-Colour Scheme)</a></li>
          <li><a href="#세상을-멈추는-조정-stop-the-world-coordination" id="markdown-toc-세상을-멈추는-조정-stop-the-world-coordination">세상을 멈추는 조정 (Stop-The-World Coordination)</a></li>
          <li><a href="#점진적-컬렉션-incremental-collection" id="markdown-toc-점진적-컬렉션-incremental-collection">점진적 컬렉션 (Incremental Collection)</a>            <ul>
              <li><a href="#쓰기-배리어" id="markdown-toc-쓰기-배리어">쓰기 배리어</a></li>
              <li><a href="#시작점-스냅샷-snapshot-at-the-beginning-invariant" id="markdown-toc-시작점-스냅샷-snapshot-at-the-beginning-invariant">시작점 스냅샷 (Snapshot-At-The-Beginning Invariant)</a></li>
            </ul>
          </li>
        </ul>
      </li>
      <li><a href="#마크-컴팩트-컬렉션-mark-compact-collection" id="markdown-toc-마크-컴팩트-컬렉션-mark-compact-collection">마크 컴팩트 컬렉션 (Mark-Compact Collection)</a></li>
      <li><a href="#복사-컬렉션-copying-collection" id="markdown-toc-복사-컬렉션-copying-collection">복사 컬렉션 (Copying Collection)</a>        <ul>
          <li><a href="#체이니의-방법-cheneys-copying-collection" id="markdown-toc-체이니의-방법-cheneys-copying-collection">체이니의 방법 (Cheney’s Copying Collection)</a></li>
          <li><a href="#베이커의-방법-bakers-incremental-copying-collection" id="markdown-toc-베이커의-방법-bakers-incremental-copying-collection">베이커의 방법 (Baker’s Incremental Copying Collection)</a></li>
        </ul>
      </li>
    </ul>
  </li>
  <li><a href="#세대-별-컬렉션-generational-collection" id="markdown-toc-세대-별-컬렉션-generational-collection">세대 별 컬렉션 (Generational Collection)</a>    <ul>
      <li><a href="#쓰기-배리어-또" id="markdown-toc-쓰기-배리어-또">쓰기 배리어 (또?)</a></li>
    </ul>
  </li>
  <li><a href="#고급-기능들" id="markdown-toc-고급-기능들">고급 기능들</a>    <ul>
      <li><a href="#gc-파라미터" id="markdown-toc-gc-파라미터">GC 파라미터</a></li>
      <li><a href="#약한-포인터-weak-pointer" id="markdown-toc-약한-포인터-weak-pointer">약한 포인터 (Weak Pointer)</a>        <ul>
          <li><a href="#레퍼런스-카운팅에서" id="markdown-toc-레퍼런스-카운팅에서">레퍼런스 카운팅에서</a></li>
          <li><a href="#트레이싱-컬렉션에서" id="markdown-toc-트레이싱-컬렉션에서">트레이싱 컬렉션에서</a></li>
        </ul>
      </li>
      <li><a href="#종료-처리-finalisation" id="markdown-toc-종료-처리-finalisation">종료 처리 (Finalisation)</a>        <ul>
          <li><a href="#실행-시점이-불확실함" id="markdown-toc-실행-시점이-불확실함">실행 시점이 불확실함</a></li>
          <li><a href="#올바른-실행-순서가-뭔지-모름" id="markdown-toc-올바른-실행-순서가-뭔지-모름">올바른 실행 순서가 뭔지 모름</a></li>
          <li><a href="#객체-부활-object-resurrection" id="markdown-toc-객체-부활-object-resurrection">객체 부활 (Object Resurrection)</a></li>
          <li><a href="#초기-구현" id="markdown-toc-초기-구현">초기 구현</a></li>
        </ul>
      </li>
      <li><a href="#이페메론-ephemeron" id="markdown-toc-이페메론-ephemeron">이페메론 (Ephemeron)</a></li>
    </ul>
  </li>
  <li><a href="#재밌는-사실들" id="markdown-toc-재밌는-사실들">재밌는 사실들</a>    <ul>
      <li><a href="#존-매커시의-생각" id="markdown-toc-존-매커시의-생각">존 매커시의 생각</a></li>
      <li><a href="#가비지-컬렉션을-위한-하드웨어" id="markdown-toc-가비지-컬렉션을-위한-하드웨어">가비지 컬렉션을 위한 하드웨어</a></li>
      <li><a href="#듀얼-duality" id="markdown-toc-듀얼-duality">듀얼 (Duality)</a></li>
      <li><a href="#비용" id="markdown-toc-비용">비용</a></li>
    </ul>
  </li>
</ul>

<p>모든 프로그램은 메모리가 필요하다.</p>

<p>자동으로 관리되는 스택은 크기가 제한적이다. 보통 윈도우는 1MB이고 리눅스는 8MB다. 튜닝으로 이 값을 조절할 순 있겠지만 그래도 한계가 있다. 더 큰 크기의 계산을 하려면 스택을 벗어나 힙 메모리가 필요하다. 프로그램은 커널에게 부탁해서 힙 메모리를 얻을 수 있다. 힙 메모리를 가지고 필요한 계산을 끝내고 나면 다 쓴 메모리는 다시 커널에게 돌려줘야 한다. 가상 메모리도 물리 메모리도 모두 유한한 자원이기 때문이다. 제때 안돌려주고 계속 메모리를 얻기만 하면 계속된 페이징, 스와핑, 페이지 폴트, 쓰레싱(Thrashing) 등의 이유로 성능이 계속 곤두박질치다가 결국 커널은 패닉에 빠질 것이다.</p>

<p>그래서 보통은 프로그래머가 직접 메모리를 관리하는 코드를 작성한다. 필요한 만큼 커널에게 할당받아서 쓰고 (Memory Allocation, 메모리 할당), 다 쓰고 나면 다시 커널에게 돌려준다 (Free, 메모리 해제). 이 짝을 맞추는 게 기본이다. 그러나 프로젝트의 요구사항이 복잡해지고 프로그래밍 언어의 고급 기능이 많아질수록 (예: 예외 처리, 코루틴), 이 짝을 맞추는 일이 어려워진다. 무엇보다 사람은 실수를 저지른다. 이미 돌려줘서 유효하지 않은 메모리를 사용해버리거나 (Use After Free), 까먹고 돌려주지 않은 채로 계속 있거나 (Memory Leak), 같은 메모리를 여러 번 돌려주는 (Double Free) 등의 실수는 너무도 흔해서 각각에 이름이 붙을 정도다. 그리고 이런 오류는 적어도 프로그램의 성능을 깎아먹거나, 치명적인 보안 취약점을 노출시키거나, 프로그램을 죽여버린다.</p>

<p>그래서 자동으로 메모리를 관리하는 방법이 연구되기 시작한다. 최초의 자동 메모리 관리 방법은 1959년에 Lisp 프로그래밍 언어에 도입되었다. Lisp의 창시자이자 인공지능 연구자 존 매카시 님의 <a href="https://www-formal.stanford.edu/jmc/recursive.pdf">관련 논문</a>의 27페이지에는 트레이싱 컬렉션(Tracing Collection)에 대한 기초적인 아이디어, 즉 <em>프로그램의 어떤 부분에서도 찾을 수 없는(닿을 수 없는) 데이터는 버려진 것으로 간주하고 나중에 재활용하는 방법</em>을 설명하고 있다.</p>

<blockquote>
  <p>… Such a register may be considered abandoned by the program because its contents can no longer be found by any possible program; hence its contents are no longer of interest, and so we would like to have it back on the free-storage list. …</p>
</blockquote>

<p>뱀발로 7번 각주가 꽤 재밌는데:</p>

<blockquote>
  <p>We already called this process “garbage collection”, but I guess I chickened out of using it in the paper - or else the Research Laboratory of Electronics grammar ladies wouldn’t let me.</p>
</blockquote>

<p>논문에서는 이렇게 각주에 딱 한번만 등장한 “가비지 컬렉션”이라는 이름이 지금은 표준적인 이름으로 널리 쓰이고 있다는 사실이 아이러니하다.</p>

<h1 id="왜-필요할까">왜 필요할까?</h1>
<p>“애초에 가비지 컬렉션이 왜 필요함? 처음부터 메모리 이슈 없게 잘 짜면 되는 거 아님?” 이라고 생각할 수 있다. 과거의 나도 그랬다. 하지만, 비단 가비지 컬렉션이 아니더라도, 메모리 관리를 위한 다양한 방법론이 여전히 연구 되고 있는 데에는 다 이유가 있다.</p>

<h2 id="역사가-말해줌">역사가 말해줌</h2>

<p>먼저 과거를 한번 살펴보자. 최초의 가비지 컬렉션이 Lisp 언어에 도입된 이유 중 하나는, 당시 메이저 언어 였던 ALGOL 언어에서 댕글링 포인터 문제, 그러니까 쓰던 메모리를 이미 해제했는데 그걸 모른채로 프로그램의 다른 부분에서 해제된 메모리 주소를 계속 갖고 있다가 사용해버리는 심각한 버그가 큰 이슈였다 (Use After Free). 그 당시 프로그래머라면 정말 똑똑한 소수의 선택된 사람들일텐데, 그런 친구들에게도 메모리를 관리하는 일은 쉽지 않았던 것이다.</p>

<p><strong>근데 이건 지금도 해결되지 않았고, 오히려 인터넷이 발전하면서 더 심각해졌다</strong>. Use After Free 버그는 이제 단순히 프로그램을 죽이는 데서 그치지 않고 프로그램의 보안 취약점이 되어 실제적인 위협이 되어버렸다. <a href="https://www.microsoft.com/en-us/msrc/blog/2019/07/we-need-a-safer-systems-programming-language">마이크로소프트의 한 조사</a>에 따르면 매년 CVE (공개적으로 알려진 보안 취약점에 아이디를 붙이는 시스템) 의 약 70%가 메모리 안전성 문제라고 한다. <a href="https://www.zdnet.com/article/chrome-70-of-all-security-bugs-are-memory-safety-issues/">구글에서도 비슷한 결과</a>를 발표한 적이 있는데, 크롬의 심각한 보안 관련 버그 중 70%가 메모리 관련 오류, 그 중 절반이 Use After Free였다고 한다.</p>

<p>1960년대에 발견된 문제가 2020년대에도 여전히 보안 취약점의 대다수를 차지한다는 사실은 “사람이 직접” 메모리를 관리하는 것이 얼마나 어려운지를 보여준다.</p>

<h2 id="문제의-난이도">문제의 난이도</h2>

<p>“메모리 이슈 없게 잘 짜면 되는 거 아님?”이 얼마나 어려운지를 보여주는 또 다른 명확한 근거는 바로 이 문제 자체의 본질적인 고난도이다. 메모리 오류의 대부분은 결국 커널로부터 힙 메모리를 할당 받아서 (<code class="language-plaintext highlighter-rouge">malloc</code>) 쓴 다음에 이걸 적절한 타이밍에 돌려줘야 (<code class="language-plaintext highlighter-rouge">free</code>) 하는 이 <em>타이밍</em>이 어긋나서 발생하는 것이다. 그런데 이렇게 메모리를 할당하고 돌려주는 작업이 필요한 시점을 정확하게 예측하는 것은 <strong>이론적으로 불가능하다</strong>. 다시 말해, “여기서 할당되어서 사용되던 메모리가 코드의 여기에서 딱 사용이 끝날테니 여기다가 <code class="language-plaintext highlighter-rouge">free</code>를 넣으면 되겠다” 라는 분석이 원천적으로 불가능하다. 왜냐하면 코드만 가지고는 할당된 메모리의 수명을 알아내는 알고리즘은 만들 수 없기 때문이다 (Undecidable).<sup id="fnref:2"><a href="#fn:2" class="footnote" rel="footnote" role="doc-noteref">1</a></sup> 그렇다고 프로그램이 끝나는 시점에 한꺼번에 메모리를 돌려줘버리면 애초에 메모리를 관리하려는 의미가 없지. 개인적으로 객체의 메모리 수명주기는 요구사항에 가깝지 않나 하는 생각이 든다.</p>

<p>좀더 쉬운 문제로, “어떤 객체의 메모리는 크기는 이정도이고 수명은 이정도다”라는 정보가 미리 알려져 있을 때 모든 객체들의 할당과 해제 시점을 잘 배치해서 메모리 사용량을 최소화 하도록 하는 <em>동적 스토리지 할당 (Dynamic Storage Allocation, DSA)</em> 문제를 한번 생각해보자. 그런데 이 문제는 NP-Complete 임이 증명되어 있다. 즉, 메모리를 잘 배치해서 최소한의 메모리만 사용하도록 계산하는 알고리즘이 있긴 있는데, 언제 끝날지 모른다. 다시 말해 메모리의 생명주기(Lifetime)가 “이미 알려져 있는” 경우에도 최적의 배치를 찾는 것이 무척 어렵다는 말이다.</p>

<p>정리하면, 메모리를 할당하고 해제하는 작업을 정확하게 계산하는 것은 이론적으로 불가능하고, 그것보다 더 쉬운 문제의 경우에도 무척 어렵다.</p>

<h2 id="인지적-부하">인지적 부하</h2>

<p>내가 중요하게 생각하는 것 중 하나는 바로 인지적인 부하이다 (Cognitive Load). 현대의 대규모 소프트웨어를 개발하는 일은 어렵다. 설령 작은 규모를 혼자서 만들더라도 현재의 나와 미래의 나는 다른 사람이다. <strong>코드는 쓰이는 것보다 훨씬 더 많이 읽힌다</strong>. 관련해서 많은 격언들이 있다<sup id="fnref:10"><a href="#fn:10" class="footnote" rel="footnote" role="doc-noteref">2</a></sup>. 아무튼, 어떤 코드 베이스를 이해하는 작업은 어렵다.</p>

<p>예를 들어, 어떤 프로젝트의 코드 한 줄을 이해하고 싶다고 하자. 만약 이 코드가 속한 함수나 파일을 넘어서, 코드 베이스 전체, 심지어는 프로젝트가 의존하고 있는 패키지를 다 따라가서 모든 것들을 고려해야 한다면, 이는 결코 쉬운 작업이 아니다 (Global Reasoning). 반면에, 그 코드의 근처의 로직만 살펴봐도 충분하다면, 이 작업은 한결 수월해질 것이다 (Local Reasoning).</p>

<p>이렇게 근처 코드만 봐도 로직을 이해할 수 있는, 진정한 의미의 “모듈화된 프로그래밍 (Modular Programming)”을 위해서는 가비지 컬렉션이 필요하다. 모듈 사이에 의존성이 생기는 것을 막을 수는 없다. 하지만 모듈이 계산을 위해 메모리 할당을 필요로 할 때 이 메모리의 소유권에 대한 분쟁이 발생한다 (Ownership). 특히 모듈 객체의 생명주기는 <strong>전역적인 속성</strong>이다. 예를 들어, 모듈 A가 할당하는 어떤 객체를 모듈 B와 C가 공유할 때, 이 객체가 언제 해제되어야 하는지를 알기 위해서는 이 세 모듈을 <strong>모두</strong> 추적해야만 한다. 그런데 이렇게 모듈이 사용하는 메모리도 생각해야 한다면 핵심 로직에 대한 이해가 급격하게 어려워진다. 안그래도 어려워 죽겠는데 메모리 관리까지 해야 한다니.</p>

<p>그 외에도 모듈 객체의 메모리를 안전하고 정확하게 해제하기 위한 다양한 추가 구현이 런타임에 상당한 비용을 초래할 수도 있다. 멀티 쓰레드 어플리케이션이면 동기화도 고려해야 한다. 아무튼 간에 복잡해진다.</p>

<p>결국 언어 설계는 안전성, 표현력, 성능, 사용성 사이의 트레이드오프를 고려해야 한다. 가비지 컬렉션은 약간의 성능을 희생하지만 안전성, 표현력, 사용성을 모두 챙겨갈 수 있는 좋은 선택지이다.</p>

<h1 id="추상화">추상화</h1>
<p>서론이 길었는데 그럼 이제 본격적으로 가비지 컬렉션에 대해서 얘기해보자.</p>

<p>존 매커시의 원조 아이디어를 다시 가져오면: “프로그램의 어디에서도 닿지 않는 메모리는 더 이상 쓰이지 않는 것으로 간주하고, 이것들을 따로 모아뒀다가 나중에 재활용한다.” 이로부터 가비지 컬렉션을 추상적인 두 단계로 나눌 수 있다.</p>
<ol>
  <li><strong>가비지 찾기 (Garbage Detection)</strong>: 살아있는 객체(Live Object, Reachable Object)와 가비지 객체(Dead Object, Unreachable Object, Garbage)를 구분한다.</li>
  <li><strong>가비지 수집 (Garbage Collection)</strong>: 가비지 객체의 메모리를 수집한다. 곧바로 해제할 수도 있고, 아니면 따로 모아놨다가 재활용할 수도 있다.</li>
</ol>

<p>실제로 이 두 단계는 완전히 구분되지 않은 채로 얽혀 있을 수 있고, 특히 수집 방법은 찾는 방법에 크게 의존하게 된다.</p>

<p>좀더 자세하게 들어가기 전에 가비지 컬렉션 세계에서 공통적으로 나오는 몇 가지 재밌는 개념들이 있다.</p>

<h2 id="루트-집합-root-set">루트 집합 (Root Set)</h2>
<p>실행 중인 프로그램이 즉시 사용할 수 있는 객체들을 <strong>루트 집합</strong>이라고 한다. “즉시 사용할 수 있다”는 뜻은 어떤 포인터를 따라가지 않고도 곧바로 접근 가능하다는 뜻이다. 예를 들면, 지금 실행 중인 함수의 스택에 올라가 있는 변수, 글로벌 변수, 정적 모듈, 등이 있다.</p>

<p>이걸 바탕으로 앞의 두 단계 추상화를 다시 생각해보자. 그러면 “가비지 찾기” 단계는 곧 “루트 집합에서부터 시작해서 닿을 수 있는 모든 객체”를 찾는 문제가 된다<sup id="fnref:4"><a href="#fn:4" class="footnote" rel="footnote" role="doc-noteref">3</a></sup>.</p>

<h2 id="컬렉터와-뮤테이터">컬렉터와 뮤테이터</h2>
<p>보통 가비지 컬렉션은 프로그래밍 언어에 기본적으로 장착되어 있거나 어떤 라이브러리에 핵심 컴포넌트로 구현되어 있다. 이때 가비지 컬렉션을 담당하는 에이전트를 가비지 컬렉터, 혹은 그냥 컬렉터(Collector)라고 부른다. 반면 흥미롭게도 가비지 컬렉션 연구자들은 사용자 프로그램을 “뮤테이터(Mutator)”라고 부르는데, 왜냐하면 컬렉터가 관리해야 하는 객체들의 메모리 상태를 사용자 프로그램이 계속해서 바꾸기 (mutate) 때문이다. 처음 이 이름을 들었을 때 철저하게 가비지 컬렉션 입장만 생각하고 이름 지은 것 같아서 웃겼다.</p>

<h2 id="박싱-boxing">박싱 (Boxing)</h2>
<p>가비지 찾기 단계가 완료되고 나면 가비지 수집 단계에서 컬렉터가 각각의 객체에 대해서 “얘는 가비지임” 혹은 “얘는 가비지 아님”을 알 수 있어야 한다. 이게 가능하려면 해시 테이블에 객체마다 메타데이터를 기록하던지, 아니면 객체마다 추가로 메타데이터를 덧씌워서 기록하던지, 둘 중 하나밖에 없다. 해시 테이블은 생각보다 비용이 크고 캐시 친화적이지 못하기 때문에 대부분의 가비지 컬렉션은 후자의 방법, 즉 객체마다 메타데이터를 덧씌우는 방식을 택하는데 이걸 <strong>박싱</strong>이라고 한다.</p>

<p>박싱은 그 이름처럼 객체를 어떤 상자(Box)에다 넣고 상자 겉면에 객체와 관련된 메타데이터를 기록하는 방법이라고 볼 수 있다. 기록할 메타데이터로는 객체의 생존여부, 객체의 지금까지의 수명, 객체의 크기, 객체가 담고 있는 값의 타입 등이 있다.</p>

<p>당연하지만 이로 인해 발생하는 오버헤드는 피할 수 없는 트레이드 오프이다. 먼저 헤더(박스 겉면)의 메타데이터를 봐서 메모리 레이아웃을 파악한 뒤에 필드(포인터)를 따라가서 실제 값이 담긴 객체에 접근해야 한다. 헤더와 필드는 서로 다른 메모리 구역에 위치할 수 있다. 그래서 <a href="../virtual-memory">간접적인 메모리 오버헤드</a> 때문에 캐시 미스가 자주 발생할 수 밖에 없다.</p>

<hr />

<p>그럼 이제 진짜 가비지 컬렉션을 얘기해보자. 방법은 크게 두 갈래로 나뉜다.</p>

<p>각 객체의 참조 횟수를 추적해서 0이 되면 즉시 수집하는 <strong>레퍼런스 카운팅(Reference Counting)</strong>과, 주기적으로 지금 쓰고 있는 객체와 안쓰는 가비지를 추적해서 가비지만을 수집하는 <strong>트레이싱 컬렉션(Tracing Collection)</strong>이 있다.</p>

<h1 id="레퍼런스-카운팅">레퍼런스 카운팅</h1>
<p>레퍼런스 카운팅은 두 단계 추상화를 다음과 같이 구현한다.</p>
<ol>
  <li>가비지 찾기: 모든 객체를 박싱해서 각 객체마다 지금 사용되고 있는 횟수를 기록한다. 프로그램이 실행되는 동안 객체를 참조하면 횟수를 증가시키고 객체 사용이 끝나면 횟수를 감소시킨다. 그러다가 참조 횟수가 0이되는 즉시 가비지로 판단한다.</li>
  <li>가비지 수집: 참조 횟수가 0인 객체의 모든 메모리를 해제한다.</li>
</ol>

<p>이 방식의 특징은 위의 1, 2가 동시에 일어난다는 점이다.</p>

<p>레퍼런스 카운팅은 여러가지 장점이 있다. 일단 아이디어가 간단해서 이해하기 쉽다. 그래서 메모리 관리 뿐 아니라 리소스 관리에도 쓰인다. 그리고 객체를 다 쓴 <em>즉시</em> 수집하기 때문에 객체의 수명이 명확한 편이라 메모리를 필요한 만큼만 사용하는 경향이 있다. 이 덕분에 객체에 대한 종료 처리도 꽤 예측한 대로 동작한다 (Finalisation). 응답성도 좋아서 실시간 시스템에 많이 채택되었다. 끝으로 아이디어가 간단한 만큼 구현도 간단하다고 하는데… CPython 구현 코드에서 레퍼런스 카운터를 <a href="https://github.com/search?q=repo%3Apython%2Fcpython%20py_incref&amp;type=code">늘리거나</a> <a href="https://github.com/search?q=repo%3Apython%2Fcpython+py_decref&amp;type=code">줄이는</a> 코드를 검색하면 엄청 많은 곳에서 발견되어서, 개인적으로 그렇게 쉬울 거라는 생각은 들지 않는다.</p>

<p>그런데 주변을 둘러보면, 레퍼런스 카운팅<strong>만</strong>을 가비지 컬렉션으로 채택한 언어나 프로그램은 거의 없다. 그 이유는 다음과 같은 몇 가지 근본적인 한계 때문이다.</p>

<h2 id="정확성의-한계">정확성의 한계</h2>

<p>가장 큰 한계는 바로 그 유명한 순환 참조 문제다 (Circular Reference). 어떤 두 객체가 서로를 참조해버리면 다른 모든 곳에서 이 객체에 대한 작업을 끝내더라도 이들의 참조 횟수는 항상 1(이상)이기 때문에 절대로 수집되지 않는다 (Memory Leak). 이런 경우가 얼마나 있겠냐 싶은데 실제로 꽤 많다. 예를 들면 LRU 캐시 등의 구현에 사용되는 더블 링크드 리스트나 계산 편의를 위해 부모 노드로 가는 포인터를 추가한 트리의 노드 등에서 순환 참조가 발생한다.</p>

<p>다시 말해 레퍼런스 카운팅은 <strong>가비지의 보수적인 근사값</strong>만을 판단한다는 근본적인 한계가 있다. 어떤 객체의 참조 횟수가 0이면 항상 가비지이지만, 그 역은 참이 아닐 수도 있다. 이로 인해 모든 가비지를 수집할 수 없게 되고 프로그램은 메모리를 누수한다.</p>

<h2 id="성능-오버헤드">성능 오버헤드</h2>

<p>생각보다 오버헤드가 크다는 단점도 있다.</p>

<p>먼저 모든 객체의 “레퍼런스”를 추적해야 하는데, 말 그대로 <strong>모든</strong> 객체, 즉 언어가 제공하는 원시 데이터 타입과 사용자가 정의하는 타입까지 전부 다 추적해야 한다. 이로 인해서 객체의 메모리를 미세하게 표현하고 조절하는데 제약이 생긴다. 예를 들어, 항상 메모리에 상주하는 정적 객체도 사용할 때마다 레퍼런스를 늘리거나 줄여야 한다. 또는, 금방 사용되고 버려질 생명주기를 정확하게 알고 있는 객체도 사용할 때마다 참조 횟수를 업데이트 해야 한다. 그래서 많은 가비지 컬렉션에서는 이런 경우를 위해 <a href="#약한-포인터-weak-pointer">미세 조정을 할 수 있는 도구</a>를 제공하기도 한다.</p>

<p>또, 참조 “횟수”를 세기 위해서는 헤더에 정수 값을 담아야 한다. 그런데 프로그램이 실행되면서 최대 몇 번의 참조가 이뤄질지를 미리 아는 것은 불가능하기 때문에, 적당한 크기의 정수를 선택하는 것도 어려운 문제다. 너무 큰 정수를 선택하면 오버헤드가 있고, 그렇다고 너무 작은 정수를 선택하면 정확한 참조 횟수를 담지 못할 수 있다<sup id="fnref:11"><a href="#fn:11" class="footnote" rel="footnote" role="doc-noteref">4</a></sup>.</p>

<p>게다가 어떤 커다란 객체의 카운트가 0이 되었을 때 거기 연결된 수많은 자식 객체들이 모두 다 해제되어야 하는 상황에서는 일관되고 예측 가능한 성능이 깨진다는 점도 무시할 수 없다. 이를 피하기 위해서 레퍼런스 카운팅의 최적화 연구에서는 연산을 미루는 것들이 많다 (Deferred Reference Counting).</p>

<p>결정적으로 프로그램의 작업량에 비례해서 메모리 관리 오버헤드가 늘어난다. 예를 들어 <code class="language-plaintext highlighter-rouge">a = b + 1</code> 이라는 코드를 실행한다고 하자. 그러면 <code class="language-plaintext highlighter-rouge">a</code>는 값이 쓰여지기 때문에 참조 횟수를 증가시키고, <code class="language-plaintext highlighter-rouge">b</code>는 값을 읽기 때문에 참조 횟수를 줄인다. 참조 횟수가 줄어드는 경우에는 이 값이 0이 되는지도 확인해야 한다. 두 변수를 사용하는 단순한 한 줄의 코드를 실행하는데 두 객체를 모두 업데이트 해야 한다. 사용하는 변수가 늘어날수록 오버헤드도 비례해서 증가한다. 즉, 프로그램의 작업량이 많아지면 오버헤드도 같이 증가할 수 밖에 없다.</p>

<h2 id="모든-작업이-암묵적인-쓰기-연산">모든 작업이 암묵적인 쓰기 연산</h2>

<p>개인적으로 겪었던 단점 중 하나는 바로 <strong>객체를 읽는 모든 연산이 암묵적으로 쓰기 연산이 되어버린다</strong>는 것이었다. 이로 인해서 데이터를 단순히 읽기만 하는 것이 원천적으로 불가능하다. 예를 들어 커다란 데이터 덩어리를 여러 프로세스 사이에 읽기만 하는 목적으로 공유하는 경우, 커널이 제공하는 최적화 중 하나인 <a href="../virtual-memory#cow-copy-on-write">쓰기 시 복사(Copy-on-Write)</a>를 적용하는 것이 불가능해져서 예상했던 것보다 엄청나게 많은 메모리를 잡아먹게 된다. 그래서 병렬 처리에 애를 먹었던 적이 있다. 이럴 때는 다른 라이브러리를 이용해서 읽기만 할 데이터를 공유 메모리에 올리거나 하는 방식으로 우회해야 한다.</p>

<p>모든 연산이 참조 횟수에 쓰기 작업을 해야 하기 때문에, 멀티 쓰레드 환경에서는 필연적으로 경쟁 상태가 발생한다 (Race Condition). 그래서 공유 메모리 병렬 처리 (Shared Memory Parallelism), 다시 말해 멀티 쓰레드 환경에서의 런타임 구현을 더 복잡하게 만든다. 많은 언어에서는 그래서 구현을 간단하게 하기 위해 GIL(Global Interpreter Lock)을 적용했다.</p>

<h2 id="레퍼런스-카운팅의-현재">레퍼런스 카운팅의 현재</h2>

<p>이러한 한계들로 인해서 현대에는 레퍼런스 카운팅만을 도입한 언어는 잘 없다. 보통은 다음 둘 중 하나를 택한다.<sup id="fnref:3"><a href="#fn:3" class="footnote" rel="footnote" role="doc-noteref">5</a></sup></p>
<ul>
  <li>하이브리드 접근: 레퍼런스 카운팅을 기본으로 하되, 순환 참조를 피하기 위해서 보조로 트레이싱 컬렉션을 같이 장착한다. 예를 들면 파이썬과 스위프트가 있다.</li>
  <li>제약된 레퍼런스 카운팅: 순환 참조를 방지하도록 소유권 규칙을 강제한다. Rust의 <code class="language-plaintext highlighter-rouge">Rc&lt;T&gt;</code>가 있다.</li>
</ul>

<p>그리고 레퍼런스 카운팅은 생각보다 최적화할 여지가 많지 않은 것 같다. 왜냐하면 가비지 컬렉션과 관련된 연구는 주로 아래에 설명할 트레이싱 컬렉션을 기반으로 하는 것이 많기 때문이다.</p>

<h1 id="트레이싱-컬렉션">트레이싱 컬렉션</h1>
<p>트레이싱 컬렉션은 이름 그대로 가비지를 “추적해서” 수집한다. 구체적으로는 가비지 컬렉션 추상화의 두 단계, 가비지를 찾는 단계와 가비지를 수집하는 단계를 정직하게 나누어 구현한다.</p>

<p>트레이싱 컬렉션은 다양한 알고리즘들이 연구되어 왔다. 이들의 공통된 특징 중 하나는 가비지를 찾고 수집하는 방식뿐만 아니라 수집한 가비지 메모리를 곧바로 해제하지 않고 이후의 할당에 재활용하는데에도 초점이 맞춰져 있다는 것이다. 메모리 할당과 수집한 메모리를 재사용하는 것은 동전의 양면과도 같아서 트레이싱 컬렉터는 “가비지 메모리를 관리하기 위한 시스템”이라고 생각할 수도 있다.</p>

<h2 id="마크-스윕-컬렉션-mark--sweep-collection">마크 스윕 컬렉션 (Mark &amp; Sweep Collection)</h2>
<p><strong>마크 스윕 컬렉션</strong>은 이름 그대로 마킹 단계와 스위핑 단계를 나누어 구현한 알고리즘으로 존 매커시가 Lisp에 구현했던 원조 가비지 컬렉션 알고리즘이다.</p>
<ul>
  <li>가비지 찾기: 마킹 단계. 루트 집합으로부터 시작해서 닿을 수 있는 모든 객체를 추적해서 “살아있는 객체”로 구분한다.</li>
  <li>가비지 수집: 스위핑 단계. “살아있는 객체”가 아닌 모든 객체들은 죽은 객체, 즉 가비지로 판단할 수 있다. 가비지 객체들은 곧바로 해제하지 않고 수집해서 이후 할당에 재활용한다.</li>
</ul>

<p>여기까지는 되게 추상적인, 잘 알려진 설명이다. 여기서 좀더 구체적으로 파고 들면 다양한 재미난 것들이 튀어 나온다.</p>

<h3 id="프리-리스트-free-list">프리 리스트 (Free List)</h3>
<p>가비지 메모리를 따로 수집해서 모아놨다가 이후 할당에 다시 재활용하는 것은 합리적으로 보인다. 그런데 이걸 어떻게 구현할까?</p>

<p>원조 Lisp에서 도입된 방법은 바로 <strong>프리 리스트</strong>이다. 그리고 이건 생각보다 많은 곳에서 여전히 쓰이고 있다.</p>

<p>프리 리스트는 수집된 가비지 메모리 덩어리를 링크드 리스트의 형태로 관리하는 방식이다. 각각의 메모리 덩어리는 다음 메모리 덩어리를 가리키는 포인터를 담고 있다. 뮤테이터로부터 새로운 메모리 할당이 요청되면 프리 리스트를 훑으면서 적절한 크기의 메모리 덩어리를 찾아서 할당에 사용한다. 그리고 수집되는 메모리 덩어리들은 프리 리스트에 추가된다.</p>

<p>설명을 보면 알겠지만 프리 리스트는 이해하기 쉬워서 구현이 쉬운 편이지만 몇 가지 문제가 있다. 먼저 프로그램의 워크로드에 맞는 “적절한” 크기의 블록을 찾는 정책에 따라서 성능이 달라질 수 있다. 모든 메모리 할당이 링크드 리스트 탐색을 필요로 하기 때문에 이 정책은 결국 어떤 식으로 리스트를 살펴볼 것인지에 대한 것이다. 예를 들어 가장 이해가 쉬운 Next-Fit 할당은 링크드 리스트를 탐색하는 포인터를 유지하면서 그 포인터를 이용해서 리스트를 순서대로 훑다가 처음으로 만난 적당한 크기(메모리 할당 요청을 수행할 수 있는 최소의 크기)의 덩어리를 만나면 돌려준다. 리스트의 끝까지 간 경우 다시 리스트의 시작으로 돌아와서 처음부터 시작한다. 리스트가 비었거나 다 살펴봤는데도 적당한 크기가 없으면 새로 할당하는 식이다. 이 외에도 가장 작은 크기부터 탐색해서 처음으로 할당 요청 크기 이상을 만날 경우 돌려주는 First-Fit과, 가장 적절한 크기의 메모리 덩어리를 찾아서 파편화를 줄이기 위한 Best-Fit 방식 등이 있다.</p>

<p>하지만 이렇게 할당 정책을 잘 구현하고 선택하더라도 메모리 단편화를 근본적으로 해결하지는 못한다. 게다가 프로그램이 실행되면서 프리 리스트가 계속 자라기만 한다면 탐색 시간이 증가할 수도 있어서 프리 리스트 자체도 관리해야 하는 비용이 된다.</p>

<p>실제 구현에서는 이걸 조금이라도 해결하기 위해서 마치 메모리 풀처럼 여러 개의 적당한 크기의 블럭으로 구성된 프리 리스트를 유지하는, Segregated Free List 방식을 많이 사용한다. 예를 들면 8바이트, 16바이트, 32바이트, …. 등 특정 크기들에 대해서 각각 프리 리스트를 만들어서 관리하는 방식이다.</p>

<h3 id="삼색-조합-tri-colour-scheme">삼색 조합 (Tri-Colour Scheme)</h3>
<p>마크-스윕 컬렉션(과 그 변형)은 저마다의 방식으로 객체의 상태를 구분하는데, 가장 널리 쓰이는 방법 중 하나는 아래의 세 가지 색깔을 이용하는 방식이다.</p>

<ul>
  <li>흰색 (Unmarked)
    <ul>
      <li>마킹 단계 시작 전: 모든 노드의 <strong>초기 상태</strong>를 나타낸다. 컬렉터는 루트 집합으로부터 닿을 수 있는 흰색 노드를 하나씩 칠해 나아간다.</li>
      <li>마킹 단계 완료 시: <strong>가비지</strong>.</li>
      <li>스위핑 단계에서: <strong>수집 대상</strong>이다. 얘네들을 프리 리스트에 모아놨다가 나중에 재사용한다.</li>
    </ul>
  </li>
  <li>회색 (In-Marking): 마킹 단계에서만 임시로 쓰인다. 어떤 객체가 루트 집합으로부터 닿긴 했는데 아직 그 자식들(포인터를 따라갈 수 있는 객체들)까지는 살펴보지 않은 상태를 뜻한다.</li>
  <li>검은색 (Marked): 루트 집합으로부터 닿을 수 있는 객체들 (<strong>Reachable Objects</strong>), 즉 “살아있는 객체들 (Live Object)”을 뜻한다. 얘네들은 아직 프로그램이 사용하고 있는 객체일 수 있기 때문에 스위핑 단계에서 살려둔다. 스위핑이 끝나고 나면 다시 색깔을 뒤집어서 흰색(초기 상태)으로 돌려둔다.</li>
</ul>

<p>이걸 바탕으로 트레이싱 컬렉션은 어떤 실행 시점에서 메모리 상의 객체들로 이뤄진 그래프를 탐색하면서 색깔을 칠하는 문제로 생각해볼 수 있다. 나의 이해를 위해 이번에는 <a href="https://excalidraw.com">엑스칼리-드로우</a>로 그림을 한번 그려봤다. 각 그림의 설명은 그림 밑에 적었다.</p>

<p><img src="/assets/img/mark-init.svg" alt="초기상태" />
제일 처음에는 모든 노드(객체)들이 흰색이다. 객체를 따라 다른 객체에 접근할 수 있다면 엣지가 있다 (Reachable).</p>

<p><br /></p>

<p><img src="/assets/img/mark-0.svg" alt="마킹" />
마킹 단계는 루트 집합에서 시작한다. 가장 먼저 노드를 하나씩 방문하면서 회색으로 칠한다.</p>

<p><br /></p>

<p><img src="/assets/img/mark-1.svg" alt="마킹" />
자식 노드로 넘어갈 때 부모 노드는 검은색으로 칠하고 자식 노드는 회색이 칠한다.</p>

<p><br /></p>

<p><img src="/assets/img/mark-2.svg" alt="마킹" />
이렇게 흰색 → 회색 → 검은색의 물결이 진행되는데, 모든 닿을 수 있는 노드는 회색을 기준으로 한쪽은 흰색 다른 한쪽은 검은색인 모습이 된다.</p>

<p><br /></p>

<p><img src="/assets/img/mark-n.svg" alt="마킹 끝" />
마킹 단계의 모든 탐색이 끝나고 나면 검은색 노드는 살아있는 객체이다. 그리고 흰색 노드들은 안전하게 가비지로 판단할 수 있다.</p>

<p><br /></p>

<p><img src="/assets/img/sweep.svg" alt="스위핑" />
스위핑 단계에서는 컬렉터가 관리 대상 메모리 구역을 전부 훑으면서 흰색 노드를 수집한다. 이 노드들은 나중에 재활용 하기 위해서 프리 리스트에다 추가한다.</p>

<p><br /></p>

<p><img src="/assets/img/sweep-end.svg" alt="스위핑 끝" />
스위핑 단계에서 가비지 노드들을 다 프리 스토리지 리스트에 넣고 나면, 이번 컬렉션을 살아남은 검은 노드들이 다음 컬렉션에서 관리될 수 있도록 다시 흰색으로 칠한다.</p>

<p>실제 구현은 다양한 요구사항을 만족해야 해서 디테일이 좀 다를 순 있지만 큰 그림에서는 이게 핵심이다. 참고로 원조 구현에서는 그래프 색칠에 사용되는 그래프 탐색을 DFS로 구현하였다.</p>

<h3 id="세상을-멈추는-조정-stop-the-world-coordination">세상을 멈추는 조정 (Stop-The-World Coordination)</h3>
<p>그런데 색을 칠하는 작업은 컬렉터가 노드의 포인터를 따라가는, 즉 포인터를 “읽는” 작업이다. 문제는 뮤테이터가 노드의 포인터에 값을 “쓸(write)” 수 있다는 것이다. 그래서 컬렉터와 뮤테이터가 동시에 실행하게 되면 컬렉터는 일관되지 못한 그래프를 읽을 수 있다.</p>

<p>이로 인한 가장 치명적인 이슈는 바로 <strong>마킹을 빼먹는 실수 (Missing Mark)</strong>이다. 마킹 단계에서 타이밍이 안맞으면 실제로 살아있는 객체가 검은색으로 칠해지지 않을 수 있는데, 이러면 살아있는 객체가 잘못 수집될 수 있다! 이건 아래 그림을 보면 바로 이해할 수 있다.</p>

<p><img src="/assets/img/missing-mark-0.svg" alt="동시 마킹 문제 0" />
먼저 메모리에 A → B → C 의 노드가 있다고 하자.</p>

<p><br /></p>

<p><img src="/assets/img/missing-mark-1.svg" alt="동시 마킹 문제 1" /></p>

<p>컬렉터가 A를 검은색으로 칠하고 이제 B를 방문해서 회색으로 칠했다.</p>

<p><br /></p>

<p><img src="/assets/img/missing-mark-2.svg" alt="동시 마킹 문제 2" />
근데 갑자기 뮤테이터가 나타나서 A와 C를 연결하고 B와 C 사이 연결을 끊어버리면 어떻게 될까?</p>

<p><br /></p>

<p><img src="/assets/img/missing-mark-3.svg" alt="동시 마킹 문제 3" />
컬렉터는 B에 매달린 자식 노드가 없기 때문에 B를 검은색으로 칠하고 다음 노드를 찾아 떠나가버린다. 그런데 A → C에 새로운 링크가 연결 되었음에도 불구하고, A를 이미 검은색으로 칠했기 때문에 C는 절대로 칠해지지 않는다.</p>

<p><br /></p>

<p><img src="/assets/img/missing-mark-4.svg" alt="동시 마킹 문제 4" />
그래서 C는 닿을 수 있는 살아있는 객체인데도 불구하고 흰색으로 남겨져 있다가, 곧이은 스위핑 단계에서 수집되어 버린다. 이렇게 되면 A가 가리키는 C의 메모리는 더 이상 유효하지 않게 되고, 이후 뮤테이터가 여기 접근하면 정의 되지 않은 행동에 의해 프로그램이 죽어버린다.</p>

<p><br /></p>

<p><img src="/assets/img/stop-the-world.svg" alt="멈춰" /></p>

<p>뮤테이터와 컬렉터가 동시에 동작하는 경우에는 마킹 단계의 정확성이 깨져버린다. 그래서 이를 막기 위해서 마크 스윕 컬렉션은 그 악명 높은 STW(Stop-The-World) 조정을 도입한다. 컬렉터가 모든 노드에 대한 마킹 작업을 완료할 때까지 프로그램(뮤테이터)을 완전히 멈춰서 색깔이 잘못 칠해지는 것을 막는다.</p>

<h3 id="점진적-컬렉션-incremental-collection">점진적 컬렉션 (Incremental Collection)</h3>
<p>올바른 가비지 컬렉션을 위해서 STW가 필요하다는 것은 자명하다. 하지만 마크 스윕 컬렉션이 처음 Lisp에 도입됐던 그 당시에는 프로그램이 수 밀리초에서 수 초간 멈춰버리는 것이 비일비재했는데, 이로 인해서 사용자 경험이 좋지 못했다. 그래서 초기의 마크 스윕 컬렉션은 이 나쁜 응답성 때문에 실시간 어플리케이션에 적합하지 못하다는 의견이 많았다.</p>

<p>이걸 해결하기 위한 것이 바로 점진적 컬렉션이다. 마킹을 할 때 뮤테이터를 멈춘 다음 처음부터 끝까지 한번에 진행하는게 아니라, 마킹 작업을 조금씩 잘라서 (Slicing) 수행한 뒤에 뮤테이터한테 턴을 넘기고, 이후 뮤테이터가 일정 시간 후에 다시 컬렉터한테 턴을 넘기는 방식으로 서로 턴을 주고받으면서 너무 긴 정지 시간(Pause Time)을 겪지 않도록 반응성을 챙기는 것이다. 구체적으로는 최대 정지 시간을 “예측할 수 있는” 수준으로 제한해서 가비지 컬렉터와 뮤테이터 사이를 조율할 수 있게 만들어 마크 스윕 컬렉션을 실시간 어플리케이션에 사용할 수 있을 정도의 반응성을 획득하게 만들었다.</p>

<h4 id="쓰기-배리어">쓰기 배리어</h4>
<p>하지만 점진적 컬렉션은 STW를 도입하게 했던 문제점, 즉 Missing Mark 문제점을 다시 만난다. 이걸 어떡하면 좋을까?</p>

<p>Missing Mark 문제점을 조금 다르게 <strong>“검은색 객체가 흰색 객체를 가리키면 안된다”</strong>라는 불변식으로 표현할 수 있다. 앞의 예시에서도, 컬렉터가 A → B → C의 체인을 방문하면서 차례로 A를 검은색으로 칠하고 B를 회색으로 칠하는 도중, 뮤테이터가 갑자기 끼어들어서 이미 칠해진 검은색 노드 A가 아직 칠해지지 않은 하얀색 노드 C를 가리키면서 이 불변식이 깨져버렸기 때문에 발생한 것이다.</p>

<p>이 불변식이 깨지는 걸 막기 위해서 “배리어”를 도입한다. 어떤 포인터 연산이 발생할 때 이것들을 추적해서 불변식이 깨지지 않게 하면 된다. 이때 쓰기 연산에 배리어를 도입하면 쓰기 배리어 (Write Barrier), 읽기 연산에 도입하면 읽기 배리어 (Read Barrier)라고 하고 보통은 쓰기 배리어를 더 많이 쓰는 듯 하다.</p>

<p>쓰기 배리어의 역할은 좀 헷갈릴 수도 있다. 왜냐하면 같은 “쓰기 배리어”라는 컨셉이 점진적 컬렉션에도 있고 아래에서 설명할 <a href="#쓰기-배리어-또">세대 별 가비지 컬렉션에서도 존재</a>하기 때문이다. 일단 여기서는 점진적 컬렉션을 중심으로 살펴보자.</p>

<p>구체적으로는 두 가지 전략이 있다.</p>
<ol>
  <li>다익스트라(Dijkstra)의 쓰기 배리어: 검은색 노드가 흰색 노드를 가리키려고 할 때, 이 흰색 노드를 회색으로 칠해서 불변식을 유지한다.</li>
  <li>스틸(Steele)의 쓰기 배리어: 검은색 노드를 수정할 때, 이걸 잠깐 회색으로 돌린다. 이렇게 하면 검은색 → 흰색이 아니라 회색 → 흰색이 되므로 불변식이 유지된다.</li>
</ol>

<p>쓰기 배리어는 <strong>모든</strong> 포인터 쓰기 연산에서 추가적으로 메모리를 관리하기 위한 로직을 실행하는 메커니즘이다. 모든 포인터 연산을 추적하고 자동으로 이 메커니즘을 삽입하려면 가비지 컬렉션만으로는 부족하고 컴파일러의 도움을 받아야 한다. 그래서 쓰기 배리어까지 적용된 완전한 트레이싱 컬렉션은 라이브러리나 패키지가 아니라 프로그래밍 언어 수준에서 구현될 수 밖에 없다.</p>

<h4 id="시작점-스냅샷-snapshot-at-the-beginning-invariant">시작점 스냅샷 (Snapshot-At-The-Beginning Invariant)</h4>
<p>점진적 컬렉션의 또 다른 문제점은 컬렉터가 멈춰있는 동안 뮤테이터가 새로운 객체를 계속 할당할 수 있다는 점이다. 그래서 이론적으로는 마킹 단계가 끝나지 않을 수 있다. 컬렉터가 열심히 그래프를 색칠해 나가다가 잠깐 멈추고 뮤테이터한테 턴을 넘겨줬을 때, 뮤테이터가 그래프에 새로운 닿을 수 있는 살아있는 객체들을 추가한 다음 다시 턴을 컬렉터에게 넘겨주고, … 를 끊임없이 진행한다면 충분히 가능하다.</p>

<p>그래서 이 문제를 해결하기 위해서 추가적으로 도입한 불변식이 바로 시작점 스냅샷 불변식 (Snapshot-At-The-Beginning Invariant, or SATB)이다. 마킹 단계를 시작하는 순간 전체 그래프의 스냅샷을 찍어서 여기에 찍힌 노드들만 마킹한다. 이렇게 하면 <strong>반드시 마킹 작업이 끝난다</strong>는 것이 보장되어서 효율적인 컬렉션이 가능하다.</p>

<p>근데 이거 실제로 구현은 어떻게 해야할까? 진짜로 마킹 시작할 때 뮤테이터를 멈춘 다음에 힙 메모리 전체의 상태를 스냅샷으로 찍듯이 어딘가에 저장하는 구현은 대단히 비효율적일 것이다. 실제 많은 프로그래밍 언어에서는 아래의 두 가지 기발한 핵심 메커니즘으로 SATB를 구현한다.</p>

<p>가장 먼저 쓰기 배리어의 불변식을 조금 느슨하게 한 “유아사(Yuasa)의 삭제 배리어”를 적용한다. 마킹의 올바름을 위해서 도입한 “검은 노드는 흰 노드를 가리키면 안된다”를 “강한 삼색 불변식(Strong Tri-Colour Invariant)”이라고 하자. 유아사의 핵심 아이디어는 이걸 조금 느슨하게 한 “약한 삼색 불변식(Weak Tri-Colour Invariant)”를 도입하는 것인데, 바로 “검은 노드가 가리키는 흰 노드는 반드시 다른 회색 노드로부터 닿을 수 있는 경로에 있어야 한다”는 것이다.</p>

<p>기존의 쓰기 배리어 대신 삭제 배리어를 도입하면 어떻게 되는지 살펴보자. 삭제 배리어는 그 이름대로 어떤 포인터 간의 연결이 끊어질 때, <strong>끊긴 애(삭제되는 애)를 살려두는 것</strong>이 핵심이다. 즉, 쓰기 연산이 일어날 때 덮어써지는 값을 회색으로 칠해서 보호한다. 앞의 예시를 다시 가져와서 그림으로 보자.</p>

<p><br /></p>

<p><img src="/assets/img/missing-mark-1.svg" alt="stab-0" />
A → B → C의 체인이 있다가 컬렉터가 A를 마킹하고 B를 회색으로 칠한 상황이다. 이 상태로 컬렉터가 뮤테이터한테 턴을 넘긴다.</p>

<p><br /></p>

<p><img src="/assets/img/satb-1.svg" alt="stab-1" />
먼저 뮤테이터가 A와 C를 연결한다고 하자. 이 연산은 곧 A가 가리키던 필드를 C로 <em>덮어 쓴</em> 것이다. 삭제 배리어는 A가 가리키던 <em>기존 값</em>인 B를 보호하기 위해서 B를 회색으로 칠한다(사실 이미 칠해져있음). 이렇게 하면 유아사의 약한 삼색 불변식을 만족한다.</p>

<p><br /></p>

<p><img src="/assets/img/satb-2.svg" alt="stab-2" />
그 다음 뮤테이터가 B와 C 사이를 끊는다고 하자. 이 연산은 곧 B가 가리키던 필드를 Null 값으로 <em>덮어 쓴</em> 것이다. 따라서 삭제 배리어는 역시 B가 가리키던 <em>기존 값</em>인 C를 보호하기 위해서 C를 회색으로 칠한다. 역시 약한 삼색 불변식을 만족한다.</p>

<p><br /></p>

<p><img src="/assets/img/satb-3.svg" alt="stab-3" />
컬렉터는 보수적으로 처음 마킹을 시작했을 때의 스냅샷인 A, B, C를 모두 수집하지 않고 지켜낼 수 있다.</p>

<p>여기에 추가적으로 한 가지의 메커니즘이 더 필요하다: <strong>마킹 단계에서 할당하는 모든 객체는 미리 검은색으로 마킹하고 할당한다</strong>. 이렇게 하면 이후 스위핑 단계에서 컬렉터가 마킹 중에 할당한 모든 객체를 이미 마킹했다고 여겨, 마치 마킹 초기에 스냅샷을 찍은 것과 같은 효과를 볼 수 있다.</p>

<p>많은 프로그래밍 언어에서는 점진적 컬렉션과 SATB을 동시에 구현하기 위해서 유아사의 삭제 배리어를 도입하는 경우가 많다. 예를 들면 OCaml과 Go, 그리고 Java의 G1 GC가 모두 이 방식을 구현하고 있다.</p>

<hr />

<p>이 외에도 전통적인 마크 스윕 컬렉션에는 주요한 몇 가지 한계가 있다.</p>

<p>가장 먼저 메모리 단편화가 있다<sup id="fnref:5"><a href="#fn:5" class="footnote" rel="footnote" role="doc-noteref">6</a></sup>. 앞의 예시에서는 추상적인 그래프로 표현했지만, 그래프의 노드는 실제로는 힙의 어떤 구간에 할당된 메모리 덩어리다. 프로그램의 수명이 길어질수록 힙 구간 안에서 살아있는 객체와 가비지 객체가 뒤섞이게 되는데, 이로 인해 큰 크기의 메모리를 할당하기 어려워질 수 있다. Segregated 프리 리스트 같은 걸 도입해 이걸 완화할 순 있지만 그래도 완벽하진 않다. 결국 컬렉터가 일을 더 많이 해서 메모리를 최대한 필요한 만큼만 타이트하게 관리할지, 아니면 그냥 적당히 메모리를 낭비하고 파편화를 감수하더라도 컬렉터가 일을 덜 할지, 이 트레이드 오프 사이에서 고민하는 수 밖에 없다. 프로그램이 어떤 워크로드를 갖느냐에 따라서 튜닝해야 한다.</p>

<p>또 다른 본질적인 한계로는 컬렉터가 스위핑 단계에서 <strong>관리 중인 메모리 구역 전체</strong>를 살펴봐야 한다는 것이다. “마킹”이 살아있는 루트 집합으로부터 “살아있는 객체”를 따라가는 작업인 반면, “스위핑”은 반대로 전체 메모리 중에서 마킹이 안된 애를 찾아야 하기 때문에 어쩔 수 없는 한계다. 하지만 이 작업은 하드웨어의 눈부신 발전으로 생각보다는 빠르다고 한다.</p>

<p>마지막으로 전반적으로 마크 스윕 컬렉션에서 적용되는 컬렉터와 뮤테이터의 작업들이 캐시 친화적이지 않다는 한계가 있다. 오래 사용되는 객체들은 한번 할당되고 나면 보통 거기 계속 남아있기 마련이다. 이들 사이에 있던 가비지가 수집되어 이후 할당에 쓰이게 되면 서로 용도와 수명주기가 다른 객체들이 연속된 메모리 구간 안에 섞이게 된다. 그러면 객체에 접근하는 메모리 작업의 지역성이 나빠져서 캐시 친화적이지 못하게 되어 성능의 손해를 본다. 게다가 컬렉션이 진행될수록 살아있는 객체들의 메모리 분포가 나빠질 수 있는데, 마킹 단계에서 살펴봐야 하는 노드(객체)들이 서로 너무 먼 곳에 떨어져 있게 되면 닿을 수 있는 객체를 살펴보기 위한 그래프 탐색 역시 캐시 친화적이지 않아서 마킹 작업이 느려질 수 있다.</p>

<p>물론 이것들은 다양한 최적화 기법을 통해서 일정 부분은 풀 수 있다.</p>

<h2 id="마크-컴팩트-컬렉션-mark-compact-collection">마크 컴팩트 컬렉션 (Mark-Compact Collection)</h2>
<p>마크 스윕 컬렉션의 단편화 문제를 해결하기 위해서 압축(Compact) 단계를 도입한 방법이다. 마킹이 완료되고 나면 관리 중인 힙 메모리 전체를 압축하여 살아있는 객체만 인접하도록 한 쪽으로 옮기고 (Sliding) 가비지들은 다 치워버린다. 이렇게하면 메모리 덩어리 사이에 있던 구멍을 살아있는 객체로 메우게 되어 단편화를 피할 수 있다.</p>

<p>장점으로는 압축 과정 덕분에 다양한 크기의 객체를 할당할 수 있게 된다. 그리고 살아있는 객체들이 지속적으로 연속된 공간에 접하기 때문에 지역성이 좋아져서 메모리 접근 연산도 빨라질 수 있다.</p>

<p>하지만 압축 단계가 끝나면 살아있는 객체의 주소가 바뀔 수 있기 때문에, 이들을 참조하고 있던 모든 객체들의 주소 값을 압축 이후의 값으로 업데이트 해줘야 한다. 그래서 객체들을 여러 번 전부 스캔해야 할 필요가 있고, 만약 살아있는 객체의 비율이 크다면 이는 상당한 오버헤드가 된다. 그리고 객체를 옮기는 슬라이딩 과정 자체도 비용이 상당하다.</p>

<p>그래서 요즘은 많은 언어들이 마크 스윕 컬렉션과 마크 컴팩션 컬렉션을 합친 마크 &amp; 스윕 &amp; 컴팩션 컬렉션을 적용한다. 계속 스위핑을 하다가 메모리에 대한 일정 임계점을 넘기는 순간 압축을 수행하여 메모리를 효율적으로 관리하도록 한다.</p>

<h2 id="복사-컬렉션-copying-collection">복사 컬렉션 (Copying Collection)</h2>
<p>마크 스윕 (+ 컴팩트) 컬렉션처럼 추적하는 방식 외에도 트레이싱 컬렉션으로 분류되는 방식이 바로 복사 컬렉션이다.</p>

<p>기본적으로 메모리 공간을 넉넉하게 할당한 다음 이걸 반으로 쪼갠다. 그러고 할당은 한쪽 공간에서만 한다. 계속 할당하다가 이게 꽉차면 전체(할당에 쓰인 절반)를 훑으면서 가비지는 스킵하고 살아있는 객체만 반대쪽 공간으로 옮긴다. 이게 기본이다. 구체적으로는 두 개의 비슷하면서도 조금 다른 알고리즘이 있다.</p>

<h3 id="체이니의-방법-cheneys-copying-collection">체이니의 방법 (Cheney’s Copying Collection)</h3>
<p>1970년에 개발된 복사 컬렉션 방식이다. STW와 비슷하게 Stop &amp; Copy 방식의 복사 컬렉션이다.</p>

<p>이 시대의 하드웨어 스펙의 한계로 인해 당시 Lisp에 구현된 나이브한 마크 스윕 알고리즘은 그래프를 DFS로 탐색하다가 스택 오버플로우가 날 위험이 있었다. 이를 해결하기 위해 체이니는, 1969년에 연구되었던 초기의 복사 컬렉션(Fenichel &amp; Yochelson)을 확장하여, BFS 기반의 가비지 탐지와 함께 메모리 단편화를 없애는 비재귀적인 구현을 소개했다.</p>

<p>먼저 힙을 두 개의 세미 공간, fromspace와 tospace로 나눈다.</p>
<ul>
  <li>fromspace: 모든 할당이 처음 일어나는 공간. 연속된 큰 메모리 구역에 커서를 기록해두고 이걸 증가시키면서 메모리를 할당한다. fromspace가 꽉차면 프로그램을 멈추고 가비지 컬렉션을 시작한다.</li>
  <li>tospace: 가비지 컬렉션이 시작되면 fromspace 구역에 살아있는 객체를 찾아서 tospace로 복사한다.</li>
</ul>

<p>이후에 모든 살아있는 객체가 tospace로 이동하고 나면, fromspace와 tospace를 개념적으로 바꾸고 (Flip) 멈췄던 뮤테이터를 다시 풀어준다. 그리고 이후 할당은 fromspace(예전 tospace)에서 일어난다.</p>

<p>이게 왜 트레이싱이냐고 할 수도 있는데 동작을 자세히 살펴보면 트레이싱 맞다. 역시 그림으로 보자.</p>

<p><br />
<img src="/assets/img/cheney-0.svg" alt="cheney-0" />
복사 컬렉션에서는 fromspace에서만 할당이 일어난다. 루트 집합은 fromspace에 할당된 루트 객체를 가리키는 포인터다. fromspace가 꽉차면 가비지 컬렉션이 시작된다. (그림에서 빼먹었는데 왼쪽 절반이 fromspace이고 오른쪽 절반이 tospace임)</p>

<p><br />
<img src="/assets/img/cheney-1.svg" alt="cheney-1" />
먼저 루트 객체를 tospace에 복사하고 scan과 free 포인터를 유지한다. scan은 아직 스캔하지 않은 첫번째 객체를 가리키고, free는 다음에 복사할 위치를 가리킨다.</p>

<p><br />
<img src="/assets/img/cheney-2.svg" alt="cheney-2" />
[scan, free) 구간의 객체들이 곧 “이번 컬렉션을 살아남아서 복사 완료했지만 아직 자식까지는 스캔하지 않은 객체들”이 된다. 따라서, scan부터 시작해서 한 객체 씩 (즉, 큐에서 꺼내서) 닿을 수 있는 객체를 살펴보고, 얘가 fromspace에 있으면 tospace의 free 이후에 넣으면서 free를 증가한다. 이러면 자연스럽게 tospace에 복사하는 것 자체가 큐를 이용한 BFS가 된다.</p>

<p><br />
<img src="/assets/img/cheney-3.svg" alt="cheney-3" />
이렇게 하나 씩,</p>

<p><br />
<img src="/assets/img/cheney-4.svg" alt="cheney-4" />
하나 씩,</p>

<p><br />
<img src="/assets/img/cheney-5.svg" alt="cheney-5" />
하나 씩 BFS를 수행하다 보면,</p>

<p><br />
<img src="/assets/img/cheney-6.svg" alt="cheney-6" />
결국 scan이 free를 만나게 되는데, 이러면 큐를 다 쓴 것이다.</p>

<p><br />
<img src="/assets/img/cheney-7.svg" alt="cheney-7" />
복사가 완료되고 나면 두 세미 공간을 Flip해서 fromspace와 tospace의 역할을 바꾸고 루트 집합이 가리키던 포인터를 새로 업데이트한다. 명시적인 큐를 도입하지 않았을 뿐이지 tospace 자체를 암묵적인 큐로 활용해서 살아있는 객체들을 BFS로 스캔한 것과 동일하다. (이해를 돕기 위해서 그림에 삼색 조합을 적용했지만, 실제 체이니의 구현에서는 색깔이 있진 않았다.)</p>

<p>간단해 보이는 이 알고리즘은 생각보다 빠르다. 일단 연속적인 큰 메모리 구간을 가져다 쓰는 거라서 지역성이 좋아 캐시 친화적이다. 게다가 메모리 할당이 그냥 포인터 주소를 증가시킨 다음에 돌려주는 <em>덧셈 연산</em>에 불과해서 굉장히 빠르다 (Bump Pointer Allocation). 하지만 전체 공간을 좀 넉넉하게 할당해두고 절반은 컬렉션을 위해서 남겨놔야 하기 때문에 메모리 오버헤드가 크다 (최소 100%).</p>

<h3 id="베이커의-방법-bakers-incremental-copying-collection">베이커의 방법 (Baker’s Incremental Copying Collection)</h3>
<p>1978년에 구현된 최초의 실시간 컬렉션이다. 체이니의 복사 컬렉션을 점진적으로 할 수 있게 확장했다. 체이니의 방식과 마찬가지로 fromspace와 tospace로 나뉘어져있지만, 핵심은 다음 세 가지 불변식을 지켜서 컬렉터와 뮤테이터가 동시에 실행할 수 있게끔 하는 것이다.</p>
<ul>
  <li><strong>뮤테이터는 절대 흰색 객체를 볼 수 없다</strong>.</li>
  <li>모든 새 객체는 회색으로 할당된다.</li>
  <li>읽기 배리어를 통해 흰색 객체의 접근을 막는다.</li>
</ul>

<p>체이니와의 미묘한 차이점 중 하나는 <em>새 객체는 tospace에 회색으로 할당된다</em>는 것이다. 동시에 컬렉터는 fromspace에서 살아있는 객체를 점진적으로 tospace로 복사한다. 그러면 fromspace는 점차 줄어들고, tospace에는 새로 할당된 객체(회색)와 fromspace에서 살아남아 복사된 객체로 채워진다.</p>

<p>베이커 방법의 특징은 <strong>읽기 배리어(Read Barrier)</strong>를 사용한다는 것이다. 뮤테이터가 포인터를 역참조할 때마다 그 객체가 fromspace에 있는지 확인한다. 만약 fromspace에 있다면 즉시 tospace로 복사하고 포인터를 업데이트한다. 이렇게 하면 뮤테이터는 절대 fromspace에 있는 흰색 객체를 보지 못한다.</p>

<p>그러다가 fromspace에서 모든 살아있는 객체가 tospace로 복사 완료되면 이때 Flip을 해서 fromspace와 tospace를 개념적으로 뒤집는다.</p>

<p>삼색 조합이 여기 도입되었다<sup id="fnref:21"><a href="#fn:21" class="footnote" rel="footnote" role="doc-noteref">7</a></sup>.</p>
<ul>
  <li>흰색: fromspace에서 아직 스캔되지 않은 객체.</li>
  <li>회색: tospace에 있지만 자식을 스캔하지 않은 객체.</li>
  <li>검은색: tospace에 있고 완전히 스캔된 객체.</li>
</ul>

<p>참고로 이 당시의 환경은 단일 쓰레드지만 번갈아서 실행하는 (Interleaving) 동시성 환경이 가능하던 때였다. 뮤테이터랑 컬렉터가 동시에 실행되더라도 이 불변식만 지키면 문제가 없다. 다만 Flip 연산은 일종의 transaction이라서 한번에 반영이 되어야 하기 때문에 어쩔 수 없이 프로그램을 멈춰야 했다.</p>

<h1 id="세대-별-컬렉션-generational-collection">세대 별 컬렉션 (Generational Collection)</h1>
<p>1980년대에 많이 주로 쓰이던 언어인 Lisp, Cedar Mesa, Smalltalk-80 같은 언어에는 앞에서 설명한 가비지 컬렉션들이 탑재되어 있었다. 하지만 당시의 제한적인 하드웨어로 인해서 “실시간”을 달성하는 게 쉽지 않았다고 한다. (당시 하드웨어 스펙은 1-3 MIPS, 메모리 1-3MB 수준으로 지금에 비교하면 약 100만배는 느리다.) 예를 들면, Smalltalk-80로 구현된 프로그램이 SUN 머신에서 돌아갈 때 GC로 인해서 1-20분 마다 1-2초 정도의 정지 시간을 겪었고, 몇몇 Lisp 프로그램의 경우 80초마다 4-5초 정도의 정지 시간이 있었다. 이런 긴 정지 시간은 실시간으로 상호작용하는 프로그램을 만들 수 없게 한다.</p>

<p>이 문제를 해결하기 위해서 다양한 연구가 진행되었다. 앞의 점진적 컬렉션들도 그 일부다. 이 중에서 지금까지도, 가비지 컬렉션 뿐만 아니라 수많은 메모리 관리 기법에 커다란 영향을 남긴 것이 바로 Henry Lieberman과 Carl Hewitt의 <strong>세대 가설(Generational Hypothesis)</strong>이다. 수명이 짧은 객체가 저장 공간 사용량의 상당 부분을 차지하기 때문에 얘네들을 더 빨리 수집하면 가비지 컬렉터를 충분히 최적화하여 실시간으로 상호작용할 수 있다는 것이다. 즉, <strong>“대부분의 객체는 빨리 죽는다 (Most objects die young)”</strong><sup id="fnref:8"><a href="#fn:8" class="footnote" rel="footnote" role="doc-noteref">8</a></sup>.</p>

<p>세대 가설은 가비지 컬렉션의 패러타임을 완전히 바꿔버렸다. 객체의 수명을 기준으로 컬렉터가 관리하는 힙 메모리를 두 개 이상의 세대로 나누고 각각의 세대마다 가비지 컬렉션 알고리즘을 다르게 적용하여 최적의 성능을 내도록 하는 방향으로 진화가 일어난 것이다. 세대 별 컬렉션의 탄생이었다.</p>

<p>최초의 세대 별 컬렉션은 베이커의 점진적 복사 컬렉션에 먼저 도입되었다. 복사 컬렉션의 메커니즘이 객체의 세대 간 이동과 잘 호환되어 구현이 쉬웠나보다. 무엇보다 복사 컬렉션의 경우 살아있는 객체를 다른 공간으로 옮겨야 해서 살아있는 객체가 <em>적을수록</em> 효율이 좋은데, 세대 가설에 따라 대부분은 빨리 죽을 것이므로, 이 효율성을 극대화시킬 수 있었다.</p>

<p>대부분은 두 개의 세대를 운영한다.</p>
<ul>
  <li>첫 번째 세대: 모든 객체가 처음 할당되는 곳이다. 세대 가설에 따라 여기 할당된 객체들은 대부분 살아남지 못하고 수집될 것이다. 언어마다 명칭이 다른데 마이너 힙, 어린 힙, Generation 0, 에덴 공간 등 다양한 재밌는 이름으로 불린다.</li>
  <li>두 번째 세대: 첫 번째 세대를 살아남는 객체들이 옮겨지는 곳이다. 여기까지 온 객체들은 훨씬 더 오래 살아남는 경향이 있다. 그래서 이 세대에 가비지 컬렉션이 동작하게 되면 첫 번째 세대보다는 수집되는 비율이 적을 수 있다. 메이저 힙, 성숙한 힙, Generation 1, 생존자 공간 등으로 불린다.</li>
</ul>

<p>세대 가설이 효과적인 이유 중 하나는 <strong>객체의 수명 주기를 고려해서 세대마다 서로 다른 컬렉션 알고리즘을 적용할 수 있다</strong>는 것이다.</p>
<ul>
  <li>첫 번째 세대: 세대 가설에 따라 수많은 객체가 생성되지만 금방 죽는다. 이 특성에 근거해서 많은 언어들은 첫 번째 세대의 힙 메모리에 <strong>복사 컬렉션</strong>을 도입한다. 복사 컬렉션의 할당은 포인터 주소를 더하기만 하면 되어서 속도가 대단히 빠르고, 메모리 구간이 연속적이라 스캔 속도 역시 빨라서 몇 안되는 살아남은 애들을 다음 세대로 옮기는(promotion) 작업도 효율적이다.</li>
  <li>두 번째 세대: 세대 가설에 따라 여기까지 살아남은 애들은 오래 살아남을 것이다. 그러므로 정확하고 안전하게 가비지를 수집하는 것이 중요하다. 초기에는 여기도 같은 복사 컬렉션을 도입했지만 현재는 많은 언어들이 <strong>마크 스윕 (+컴팩션) 컬렉션</strong>을 도입한다.</li>
</ul>

<p>세 개 이상의 세대를 가진 가장 성공적인 언어는 바로 C#(.NET)이다. 닷넷은 총 세 개의 단계적 세대와 하나의 별도 세대를 관리한다. Generation 0에서 살아남아 Generation 1, Generation 2로 갈수록 살아남은 객체들은 오래 살아남을 것이다. 더 오랜 세대에는 가비지 컬렉션이 호출되는 빈도가 급격히 줄어들게 되고, 따라서 매우 효율적인 실행이 가능하다.</p>
<ul>
  <li>Generation 0: 첫번째 세대.</li>
  <li>Generation 1: 첫번째 세대를 살아남은 중간 세대. 버퍼 역할을 한다.</li>
  <li>Generation 2: 마지막 세대. 길게 살아남은 객체들이 여기 있다.</li>
  <li>LOH(Large Object Heap): 85KB 이상의 큰 객체는 곧바로 여기에 할당되며 별도로 관리한다.</li>
</ul>

<p><br /></p>

<p>자바도 공식적으로는 Young Generation과 Old Generation 두 개의 세대를 운영하지만, 실제로는 Young Generation이 세 개의 영역으로 세분화되어 있다.</p>
<ul>
  <li>에덴 공간: 첫번째 세대.</li>
  <li>Survivor 0: 첫번째 세대를 살아남은 세대.</li>
  <li>Survivor 1: 역시 첫번째 세대를 살아남은 세대.</li>
</ul>

<p>에덴 공간에서 살아남은 객체들이 Survivor 0과 1 세대를 번갈아 이동하고 나중에 오래 살아남은 애들은 Old Generation으로 가게 된다.</p>

<p><br /></p>

<p>OCaml은 반면 두 개의 세대를 운영한다.</p>
<ul>
  <li>마이너 힙: 첫번째 세대. 앞에서 얘기한 체이니의 복사 컬렉션을 쓴다.</li>
  <li>메이저 힙: 오래 살아남은 세대. 마크 스윕 컴팩션 컬렉션을 쓴다. 다만 압축이 자주 일어나지는 않는다.</li>
</ul>

<p><br /></p>

<p>이렇게 두 세대로 메모리를 나누고 나면, 보통은 다음과 같은 순서를 따라 가비지 컬렉션이 동작한다.</p>
<ol>
  <li>모든 사소한(trivial) 객체는 첫 번째 세대(이후 마이너 힙으로 표현)에 할당된다.</li>
  <li>마이너 힙이 꽉 차면 마이너 힙 컬렉션이 촉발되어 마이너 힙에 대한 수집이 시작된다. 보통은 복사 컬렉션이기 때문에 빠르게 가비지가 수집된다. 여기서 살아남은 애들은 두 번째 세대(이후 메이저 힙으로 표현)로 옮겨진다.</li>
  <li>이후의 사소한 객체들도 모두 마이너 힙에 할당된다.</li>
  <li>메이저 힙이 꽉 차면 메이저 힙 컬렉션이 일어난다. 보통은 마크 스윕 컬렉션의 변형이기 때문에 앞에서 설명한 것들, 예를 들면 점진적인 STW와 SATB 등의 기법이 적용되어 최대한 효율적으로 수행된다.</li>
</ol>

<p>따라서 세대 별 컬렉션이 탑재된 언어에서는 워크로드에 따라 이 두 가지 세대의 힙 모두를 조절할 수 있는 파라미터를 잘 튜닝하는 것이 관건이다.</p>

<h2 id="쓰기-배리어-또">쓰기 배리어 (또?)</h2>
<p>세대 별 컬렉션은 사실 거의 독립적인 두 개의 가비지 컬렉터가 거의 독립적인 두 개의 힙을 관리하는 것과 마찬가지이다. 그래서 전에 없던 새로운 엣지 케이스가 생긴다: <strong>“메이저 힙의 객체가 마이너 힙의 객체를 가리킬 수 있다”.</strong></p>

<p>이게 왜 문제가 되는지 생각해보자. 예를 들어, 메이저 힙에 오래 살아남은 어떤 객체 A가 있다고 하자. 프로그램이 실행되다가 마이너 힙에 새로운 객체 B를 할당한 후에 A → B 의 참조를 만들었다. 그러면 B는 항상 A롤 통해서만 닿을 수 있는 살아있는 객체가 된다. 근데 마이너 힙 컬렉터는 마이너 힙만 관리하기 때문에 이 A → B 체인을 알 수가 없다. 그래서 이 정보를 모르면 B를 가비지로 판단하고 잘못 수집해버릴 수 있다.</p>

<p>그래서 세대 별 컬렉션은 이를 해결하기 위해 새로운 불변식을 도입한다: <strong>“마이너 힙 컬렉션 시에는 메이저 힙에서 마이너 힙으로 가는 모든 참조를 알아야 한다.”</strong></p>

<p>여기에도 “배리어”를 쓴다. 앞에서와 마찬가지로 읽기 배리어와 쓰기 배리어가 있는데, 여기서도 쓰기 배리어에 집중해보자. 점진적 컬렉션의 쓰기 배리어와 목적이 다르다.</p>
<ul>
  <li>쓰기 배리어 (점진적 컬렉션): 검은색 → 흰색 참조를 막음</li>
  <li>쓰기 배리어 (세대 별 컬렉션): 메이저 힙 → 마이너 힙 참조를 <em>추적함</em></li>
</ul>

<p>즉, 세대 별 컬렉션에서의 쓰기 배리어는 단순히 세대 간 참조를 막는게 아니라 이 참조들을 모두 추적한다. 메이저 힙에서 마이너 힙으로 가는 모든 포인터를 <strong>기억 집합(Remembered Set)</strong>이라는 자료구조에 담아두면, 이후 마이너 힙 컬렉션에서 루트 집합과 함께 이 기억 집합을 살펴볼 수 있다. 루트 집합을 확장하는 셈이다.</p>

<p>세대 별 컬렉션의 쓰기 배리어는 점진적 컬렉션의 쓰기 배리어와 마찬가지로 모든 포인터 쓰기 연산을 추적해야 하기 때문에 컴파일러의 도움이 필요하다.</p>

<h1 id="고급-기능들">고급 기능들</h1>
<p>이렇게 가비지 컬렉션은 자동으로 객체의 메모리를 할당하고, 가비지 여부를 탐지하고, 메모리를 수집해뒀다가 재사용하게 해주지만, 사실 이 방법들은 모두 프로그램의 워크로드 특성에 따라 그 효율이 다를 수 밖에 없다. 그래서 많은 언어들이 워크로드에 맞게 가비지 컬렉션을 튜닝하거나 혹은 가비지 컬렉션의 동작을 고려한 특수한 목적의 기능을 제공한다.</p>

<h2 id="gc-파라미터">GC 파라미터</h2>
<p>많은 언어가 세대 별 컬렉션을 기본으로 탑재하고 있기 때문에, 대부분 마이너 힙과 메이저 힙의 특성이나 각각의 힙에 대한 컬렉션을 조절하는 파라미터를 제공한다. 모든 파라미터를 다룰 순 없으니 여기서는 몇 가지 대표적인 것들만 예로 가져와봤다.</p>

<p>자바는 이른바 Roofline이라고 불리는 파라미터를 제공한다. JVM이 최대로 사용할 수 있는 메모리를 설정할 수 있다. 그러면 프로그램은 정해진 메모리 한도까지만 사용할 수 있고 이걸 넘는 순간 컬렉션이 동작한다. 자바의 이런 접근은 당시의 엔터프라이즈 환경에서는 적합한 것이었다. 그때는 하나의 프로그램이 하나의 머신의 리소스를 다 차지한 채로 무한정 돌면서 사용자의 요청 사항을 처리했으니.</p>

<p>하지만 점차 프로그램의 요구사항이 복잡해지면서 이런 루프라인 파라미터는 튜닝하기가 대단히 까다로워졌다. 당장 프로그램이 실행하면서 얼마나 많은 메모리를 필요로 할지 모르는 경우가 많다.</p>

<p>그래서 OCaml의 경우는 조금 다른 접근을 했다. 메모리의 최대 사용량을 고정하는게 아니라, 메모리를 얼마나 <em>낭비해도 될지</em>를 조절할 수 있는 Space Overhead라는 파라미터를 제공한다. 예를 들어 Space Overhead가 80(기본값)이면 실제로 필요한 메모리의 80%까지 추가로 낭비하는 것이 허락된다. 그래서 프로그램이 실제로 100 만큼의 메모리를 필요로 하더라도 180까지는 GC가 즉시 동작하지 않고 기다려준다.</p>

<p>사실 이외에도 많은 파라미터가 있다. 이건 컴퓨터 공학에서 언제나 있어왔던 트레이드 오프의 일종이다. 정확히는 CPU의 계산과 메모리 사이의 트레이드 오프, 혹은 뮤테이터와 컬렉터 사이의 트레이드 오프로, 메모리를 좀 낭비하되 계산(뮤테이터)을 더 많이 할지, 아니면 메모리를 타이트하게 아끼되(컬렉터) 계산에 필요한 리소스(CPU)를 좀 뺏길지를 결정하는 것이다.</p>

<p>당연히 은총알은 없고 뭐가 더 맞는 접근인지는 프로그램의 워크로드나 프로젝트의 요구사항에 따라 다르다.</p>
<ul>
  <li>실시간 시스템: GC로 인한 정지 시간을 최소화하는 것이 중요하다. 그래서 메모리를 좀 낭비하더라도 정지 시간을 최소화 하도록 튜닝한다.</li>
  <li>서버: 응답 시간의 예측 가능성이 중요하다(P99). 메모리를 좀 낭비하더라도 최악의 정지 시간이 통제 가능할 정도여야 한다.</li>
  <li>임베디드 시스템: 리소스가 제한적인 경우가 많아서 메모리 사용량을 최소화해야 한다. 뮤테이터가 계산을 좀 덜 하더라도 컬렉터한테 양보해줘야 한다.</li>
</ul>

<h2 id="약한-포인터-weak-pointer">약한 포인터 (Weak Pointer)</h2>
<p>가비지 컬렉터가 장착된 언어는 약한 포인터라는 기능을 제공하는 경우가 많다. 약한 포인터, 또는 약한 참조(Weak Reference)는 컬렉터와 상호작용하는 특수한 포인터로, 이름 그대로 힘이 약해서 (…) 얘가 가리키는 메모리는 컬렉터가 회수하는 걸 막지 못해 언제든지 회수당할 수 있다. 즉, <strong>객체의 생명주기에 영향을 주지 않는다</strong>.</p>

<p>그럼 이걸 어디다 쓸 수 있을까? 약한 포인터의 활용은 가비지 컬렉션의 종류에 따라서 조금 다르다.</p>

<h3 id="레퍼런스-카운팅에서">레퍼런스 카운팅에서</h3>
<p>약한 포인터는 원래 레퍼런스 카운팅의 <em>순환 참조 문제</em>를 부분적으로 해결하기 위한 도구로 탄생했다. 두 객체 A와 B가 있을 때 A → B, B → A 방향으로 순환 참조가 일어날 수 있는데, 이때 B → A를 약한 참조로 바꾸면 올바르게 메모리를 회수할 수 있게 된다.</p>

<p>예를 들어, 더블 링크드 리스트에서 <code class="language-plaintext highlighter-rouge">next</code> 포인터는 강한 참조로, <code class="language-plaintext highlighter-rouge">prev</code> 포인터는 약한 참조로 가리키면 순환 참조를 막을 수 있다. 하지만 이 방법은 프로그래머가 미리 순환 참조를 예측하고 설계 단계에서 약한 포인터를 직접 추가해야 하는 번거로움이 있다.</p>

<h3 id="트레이싱-컬렉션에서">트레이싱 컬렉션에서</h3>
<p>트레이싱 컬렉션에서는 주로 <em>캐싱 및 메모이제이션</em>에 활용된다. 캐싱은 성능을 위해서 데이터를 잠깐만 가지고 있다가 메모리가 부족하면 얼마든지 버려도 된다. 그래서 캐싱할 데이터는 약한 포인터로 갖고 있는게 적절하다.</p>

<p>그런데 실제로 약한 포인터로 캐싱을 구현하면 너무 약해서(?) 컬렉터가 얘네를 너무 빠르게 수집해버릴 수 있는데, 이러면 캐싱 효율이 떨어진다. 그래서 보통 캐싱을 위해서는 조금 덜 약한(?) 포인터를 제공하기도 한다.</p>

<p>예를 들면, 자바에서는 진짜 약한 포인터인 <code class="language-plaintext highlighter-rouge">WeakReference</code>가 있긴 하지만, 캐싱을 구현할 때에는 좀 덜 약한 포인터인 <code class="language-plaintext highlighter-rouge">SoftReference</code>를 쓴다. <code class="language-plaintext highlighter-rouge">SoftReference</code>는 메모리가 충분할 때에는 유지되다가 메모리가 정말 부족할 때에만 수집된다.</p>

<h2 id="종료-처리-finalisation">종료 처리 (Finalisation)</h2>
<p>가비지 컬렉션은 메모리 관리를 자동화해주지만 메모리가 아닌 다른 리소스, 예를 들어, 파일 핸들, 네트워크 소켓, 데이터베이스 커넥션과 같은 외부 시스템과 연동된 리소스들은 관리해주지 못한다. 이런 리소스와 연결된 프록시 객체를 다 쓰고 나면 단순히 메모리를 수집하기 전에 리소스 핸들을 닫는 “정리 작업”을 해줘야 한다.</p>

<p>가비지 컬렉션 위에서 이 정리 작업을 자동으로 해주기 위해 탄생한 것이 바로 <strong>종료 처리</strong> 혹은 <strong>종료자(Finaliser)</strong>이다. 어떤 객체가 “거의 수집 가능(Almost Collectable)”하다고 판단되면 컬렉터는 그 객체를 수집하기 전에 뮤테이터한테 이걸 알려준다. 그러면 뮤테이터는 그 객체와 연관된 종료 처리 작업(보통 함수)을 진행해서 외부 리소스도 올바르게 관리할 수 있다.</p>

<p>근데… 이게 생각보다 되게 까다롭다.</p>

<h3 id="실행-시점이-불확실함">실행 시점이 불확실함</h3>
<p>레퍼런스 카운팅에서는 참조 횟수가 0이 되면 가비지이니 비교적 “거의 수집 가능” 시점이 명확하다. 하지만, <strong>트레이싱 컬렉션</strong>은 다르다. 객체가 정말로 가비지가 되는 시점과 컬렉터가 마킹 단계에서 이 사실을 발견하는 시점 사이에는 시차가 있다. 심지어 일부 가비지는 프로그램이 끝날 때까지 수집되지 않을 수도 있다. 그래서 종료 처리가 언제 적절하게 실행될지 확실하게 보장할 수 있는 방법이 없다.</p>

<h3 id="올바른-실행-순서가-뭔지-모름">올바른 실행 순서가 뭔지 모름</h3>
<p>종료 처리를 올바른 순서로 실행하는 것도 골치 아프다. 직관적으로는 위상정렬을 통해 의존성이 없는 객체부터 종료 처리를 실행하는게 맞을 것 같다. 하지만 이론적으로는 객체들 사이에 싸이클이 있을 수 있어서 위상정렬이 불가능한 경우가 있다.</p>

<p>그럼 아예 랜덤한 순서로 실행하는 건 어떨까? 이러면 구현은 간단해지겠지만 어떤 종료 처리 A가 참조하는 다른 객체가 <strong>먼저 종료 처리 되어서</strong> Use After Free를 일으킬 수 있다. 즉, 종료 처리 작성을 엄청나게 어렵게 만든다.</p>

<h3 id="객체-부활-object-resurrection">객체 부활 (Object Resurrection)</h3>
<p>더 큰 문제는 <strong>종료 처리가 죽은 객체를 되살릴 수 있다</strong>는 것이다! “거의 수집 가능”한 객체도 다른 객체에 접근할 수 있는데, 종료 처리 코드가 자기 자신 (예를 들어 <code class="language-plaintext highlighter-rouge">self</code> 혹은 <code class="language-plaintext highlighter-rouge">this</code>)을 전역 변수나 정적 데이터 구조에 저장하면 스스로를 부활시키는 꼴이 된다. 다음 파이썬 코드가 그 예시다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">global_reference</span> <span class="o">=</span> <span class="p">[]</span>

<span class="k">class</span> <span class="nc">Zombie</span><span class="p">:</span>
    <span class="k">def</span> <span class="nf">__del__</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
        <span class="n">global_reference</span><span class="p">.</span><span class="nf">append</span><span class="p">(</span><span class="n">self</span><span class="p">)</span>

<span class="n">obj</span> <span class="o">=</span> <span class="nc">Zombie</span><span class="p">()</span>
<span class="k">del</span> <span class="n">obj</span>
</code></pre></div></div>

<p>이걸 감지하려면 쓰기 배리어를 강제해야 하는데, 그렇다고 이게 항상 가능한 것도 아니다. 그리고 객체의 부활 가능성만으로도 가비지 컬렉션이 훨씬 복잡해지고 느려진다. 종료 처리를 감안하여 어떤 객체를 올바르게 수집하려면 최소 2번의 컬렉션이 필요하게 되기 때문이다.</p>

<h3 id="초기-구현">초기 구현</h3>
<p>초기에는 약한 포인터를 가지고 종료 처리를 구현했다. 기본 아이디어는 다음과 같다.</p>
<ol>
  <li>종료 처리가 필요한 객체가 약한 참조를 생성한다.</li>
  <li>생성한 약한 참조 객체에 <strong>종료 처리 함수(콜백)</strong>을 연결한다.</li>
  <li>컬렉터가 마킹 단계에서 객체를 수집할 때,
    <ol>
      <li>먼저 약한 참조를 따라가지 않고도 닿을 수 있는 객체를 모두 찾는다.</li>
      <li>위에서 못찾았지만 약한 참조로만 닿을 수 있는 객체를 찾는다. 이들이 바로 “거의 수집 가능”한 객체들이다.</li>
    </ol>
  </li>
  <li>“거의 수집 가능”한 객체들에 연결된 콜백을 실행한다.</li>
</ol>

<p>파이썬의 <code class="language-plaintext highlighter-rouge">weakref.finalize</code>가 이 방식을 사용한다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">weakref</span>

<span class="k">def</span> <span class="nf">cleanup</span><span class="p">(</span><span class="n">resource_id</span><span class="p">):</span>
    <span class="nf">close</span><span class="p">(</span><span class="n">resource_id</span><span class="p">)</span>

<span class="k">class</span> <span class="nc">Resource</span><span class="p">:</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">resource_id</span><span class="p">):</span>
        <span class="n">self</span><span class="p">.</span><span class="n">_finalizer</span> <span class="o">=</span> <span class="n">weakref</span><span class="p">.</span><span class="nf">finalize</span><span class="p">(</span>
            <span class="n">self</span><span class="p">,</span>         <span class="c1"># 감시할 객체
</span>            <span class="n">cleanup</span><span class="p">,</span>      <span class="c1"># 정리 함수
</span>            <span class="n">resource_id</span><span class="p">,</span>  <span class="c1"># 정리 함수에 전달할 파라미터
</span>        <span class="p">)</span>
</code></pre></div></div>

<p>이렇게하면 죽은 객체가 다시 부활하는 것을 막을 수 있다. 하지만 그럼에도 여전히 다른 한계가 남아있다.</p>

<p>결론적으로 이런 여러가지 이유로 현대의 프로그래밍 언어들은 종료 처리 기능이 있긴 하지만 종료 처리 사용을 지양하도록 하는 편이다. 예를 들어 파이썬은 종료 처리 쓰지 말고 명시적으로 리소스를 관리하도록 (예: <code class="language-plaintext highlighter-rouge">with</code>) 장려하는 추세다. 역시 호환성은 어렵다.</p>

<h2 id="이페메론-ephemeron">이페메론 (Ephemeron)</h2>
<p>이페메론은 약한 참조를 조금 더 일반화하고 <strong>“거의 수집 가능함”</strong>을 재정의한다. 에피메론? 아니면 발음 기호대로 읽으면 이페머른? 정도 되는 것 같은데, 그리스어로 “하루만 사는” 또는 “덧없는” 뜻하는 Ephemeros에서 유래했다. Ephemeros는 하루살이의 어원이다. 여기서 이걸 하루살이라고 부를 순 없으니 이페메론 정도로 부르겠다.</p>

<p>이페메론은 논리적으로는 키-값 쌍을 담은 일종의 해시 테이블 (혹은 딕셔너리) 이다.</p>
<ul>
  <li>이페메론 키: 약한 포인터처럼 동작한다. 키가 가리키는 객체가 다른 곳에서 쓰이지 않으면 이는 곧 수집될 수 있다.</li>
  <li>이페메론 값: 강한 포인터이지만, <strong>키가 살아있을 때에만 유효하다</strong>. 즉, 키가 수집되면 거기 묶인 값도 같이 수집된다. 그리고 <strong>이페메론 값이 다른 이페메론 키를 참조하더라도 이는 약한 포인터로 취급한다</strong>. 이것 덕분에 값이 키를 참조하는 순환이 있어도 메모리 누수가 발생하지 않는다.</li>
</ul>

<p>실제 구현에서 이페메론은 가비지 컬렉터에 의해 특수하게 처리된다.</p>
<ol>
  <li>일단 루트 집합에서부터 강한 참조만 따라가서 마킹하고 이페메론은 건너뛴다.</li>
  <li>각 이페메론의 키가 1에 의해 마킹되었는지 확인한다. 만약 마킹되었다면, 그 값도 마킹한다 (이때 이 값이 다른 키를 참조해도 문제없다). 키가 마킹되지 않았다면, 연결된 키와 값 모두 수집 후보에 올린다.</li>
  <li>Fix Point에 도달할 때까지 2번을 반복한다.</li>
</ol>

<p>이페메론은 두 개의 연결된 문제를 해결하기 위해서 도입된 데이터 구조다. 하나는 좀더 정확한 종료 처리다. 이페메론을 활용하면 어떤 객체가 수집되기 <em>직전에</em> 알림을 줄 수 있다. 다른 하나는 속성 테이블(Property Table)이라고 불리는 데이터구조다. 보통 이런 속성 테이블은 키-값 쌍을 담은 딕셔너리로 구현되는데, 속성 테이블에 속한 값이 속성 테이블에 속한 다른 키를 가리키는 경우가 많다. 예를 들면 속성 간의 계층을 표현하기 위해서 <code class="language-plaintext highlighter-rouge">owner</code>라는 속성 필드가 다른 속성(키)을 가리킬 수 있다. 이페메론이 아니라면 메모리 누수가 나기 십상이지만, 이페메론의 특수한 시맨틱 덕분에 속성 테이블 구현이 프로그래밍 언어 수준에서 가능해진다.</p>

<p>하지만, 이페메론도 종료 처리의 근본적인 문제들을 모두 해결하지는 못한다. 여전히 언제 실행될지를 보장해주진 않고, 컬렉터가 이페메론을 특별하게 처리해줘야 해서 오버헤드가 생긴다. 또, 구현이 어려워서 버그가 생기기 쉽다.</p>

<h1 id="재밌는-사실들">재밌는 사실들</h1>
<h2 id="존-매커시의-생각">존 매커시의 생각</h2>
<p>1959년 존 매커시가 Lisp에 가비지 컬렉션을 구현했을 당시 하드웨어 환경은 지금과 비교하면 엄청나게 열악했다. 메모리가 32KB 수준이었다. MB도 아니고 KB다. 그래서 당시 Lisp의 가비지 컬렉션은 상당히 느린 편이었고, 존 매커시도 이걸 약간 임시 방편같은 느낌으로 생각했다고 한다. 시간이 흐르면 다른 연구자들과 엔지니어들이 더 나은 방법을 찾을 것이라고 믿었던 것이다. 하지만 60년도 더 지난 지금, 오히려 많은 현대의 프로그래밍 언어에서 가비지 컬렉션은 핵심 기능으로 자리 잡은 사실이 재밌다.</p>

<h2 id="가비지-컬렉션을-위한-하드웨어">가비지 컬렉션을 위한 하드웨어</h2>
<p>가비지 컬렉션은 굉장히 오랜 세월 여러가지 언어에서 연구되고 개발되어 왔다. 그러다보니 정말 다양한 접근이 시도되었는데 그 중 하나는 바로 가비지 컬렉션을 위한 특수 하드웨어가 있었다는 것이다. 1980년대에 있었던 특수 목적 컴퓨터인 Symbolics나 TI Explorer는 “Lisp 머신”(진짜 기계)였는데, 트레이싱 컬렉션의 쓰기 배리어 연산을 효율적으로 하기 위한 하드웨어 연산이 지원되는 전용 FPGA가 달려있었다. 그래서 메모리 쓰기 연산이 발생하면 자동으로 쓰기 배리어 로직을 적용해서 기록했다고 한다.</p>

<p>당시에는 이런 특수한 하드웨어가 인공지능 연구에 필수적이라고 생각했었지만 이후에 PC의 성능이 급격하게 좋아지면서 경제성이 안나와서 금방 단종되고 말았다.</p>

<h2 id="듀얼-duality">듀얼 (Duality)</h2>

<p>사실 레퍼런스 카운팅이랑 트레이싱 컬렉션은 수학적으로 듀얼이다!</p>

<p>수학에는 듀얼이라는 개념이 있다. 나는 수학은 잘 모르기 때문에 이걸 정확하게 설명할 자신은 없어서 클로드에게 물어봤다. 일단 듀얼은 개념, 정리, 수학적 구조를 다른 개념, 정리, 구조로 일대일 대응시키는 원리인데, 핵심은 다음 세 가지다.</p>
<ol>
  <li>어떤 변환을 두 번 적용하면 원래 것으로 돌아온다. (Involution)</li>
  <li>두 개념이 대칭적인 관계를 가지며 하나의 관점에서 성립하는 것이 다른 관점에서도 대응되는 형태로 성립한다.</li>
  <li>단순히 같은 것이 아니라 역 또는 보안 관계에 있는 개념들의 쌍이다.</li>
</ol>

<p>이 관점에서 레퍼런스 카운팅과 트레이싱 컬렉션을 다시 바라볼 수 있다.</p>
<ul>
  <li>트레이싱 컬렉션: 루트 집합에서 부터 시작해서, 살아있는 객체를 추적한다.</li>
  <li>레퍼런스 카운팅: 안티-루트 집합(즉, 루트 집합이 아닌 모든 객체들)에서 부터 시작해서, 죽은 객체를 추적한다.</li>
</ul>

<p>트레이싱 컬렉션은 “무엇이 여전히 쓰이고 있는지”를 추적하는 것이고, 레퍼런스 카운팅은 “무엇이 더이상 안쓰이는지”를 추적하는 것이다. 뭔가 직관적으로 서로 연관이 있을 것이라고 생각은 했는데 수학적으로 듀얼이라는 관계에 있다는 사실이 놀랍다. 좀더 엄밀한 수학적인 증명은 <a href="https://web.eecs.umich.edu/~weimerw/2008-415/reading/bacon-garbage.pdf">A Unified Theory of Garbage Collection</a> by David F. Bacon et. al (IBM Watson)을 참조하여 이해를 시도해보자.</p>

<h2 id="비용">비용</h2>
<p>그래서 가비지 컬렉션이 장착된 언어는 수동으로 메모리 관리하는 언어에 비해서 얼마나 (비)효율적일까? 쉽게 답할 수 없는 질문이다. 가비지 컬렉션만의 비용을 측정하는 일은 엄청나게 어렵다. 당장 같은 언어로 구현된 논리적으로 같은 문제를 푸는 서로 다른 알고리즘을 비교하는 것도 어려운 문제인데, 메모리 관리 방식이 서로 다른 두 언어를 비교하는 일에는 방해하는 요소들(Confounding Factors)이 너무 많다. 예를 들어, C언어와 OCaml을 비교하려고 하면 다음과 같은 것들을 고려해야 한다.</p>
<ul>
  <li>메모리 레이아웃: 논리적으로 같은 데이터 구조를 표현하더라도, 언어마다 데이터를 표현하는 메모리 레이아웃이 다르다. C에서는 64비트 정수가 곧바로 표현되지만 OCaml에서 64비트 정수를 표현하려면 박싱된 정수를 써야하거나 아니면 박싱안된 63비트 짜리 정수를 써야 한다. 구조체로 들어가면 차이는 더욱 심각해진다. 논리적으로 같은 값이라도 메모리에 배열되는 방식이 다르기 때문에 지역성에도 큰 차이가 있어 효율도 다르다. 이걸 가비지 컬렉션의 비용으로 봐야할까, 아니면 이걸 완전히 별개로 생각하고 측정해야할까?</li>
  <li>배열 바운드 체크: OCaml은 모든 배열 연산에 기본적으로 바운드 체크를 하기 때문에 상대적으로 안전한 대신 오버헤드가 숨어있다.</li>
  <li>함수 호출: C는 표준적인 함수 호출 컨벤션을 따르는 반면, 함수형 언어인 OCaml에서의 클로저는 오버헤드가 있는데, 이는 클로저 역시 가비지 컬렉션에 의해 수집될 수 있는 대상이기 때문이다. 함수 호출 방식이 서로 달라서 발생하는 비용을 어떻게 측정하고 비교할 수 있을까? 이것 역시 가비지 컬렉션의 숨겨진 비용일까?</li>
  <li>컴파일러 최적화: 수십년간 수많은 엔지니어와 연구자들이 최적화한 GCC/Clang에 비해서 OCaml의 최적화는 부족한 부분이 있을 수 있다. 이걸 어떻게 동일선 상에 놓고 비교할 수 있을까?</li>
  <li>쓰기 배리어의 오버헤드: OCaml의 세대 별 컬렉션의 정확성을 위해 도입된 쓰기 배리어는 모든 포인터 쓰기 연산에 추가적인 조건을 검사한다. 하지만 실제로 OCaml 프로파일러로 GC가 동작하는 시간(즉, 마이너/메이저 힙 컬렉션)을 측정할 때에는 이 오버헤드가 포함되지 않는다.</li>
  <li>메모리 할당자: OCaml은 앞서 설명했던 가비지 컬렉션이 제공하는 할당 방식을 쓸 수 밖에 없지만, C에는 다양하게 최적화된 할당자들이 많다. 예를 들면, tcmalloc, jemalloc 등이 있다. 이들을 모두 같은 “메모리 할당자”라고 볼 수 있을까?</li>
  <li>튜닝 파라미터: OCaml의 <code class="language-plaintext highlighter-rouge">Gc</code> 모듈에는 가비지 컬렉션의 동작을 미세 조정할 수 있는 다양한 파라미터가 있다. 그래서 특정 워크로드마다 이 값을 튜닝해서 최적의 성능과 메모리 효율 사이에서 원하는 것을 얻을 수 있다. C 프로그램과 비교할 때 어떤 파라미터 값을 기준으로 비교해야 의미가 있을까?</li>
</ul>

<p>당장 가비지 컬렉션에 대해서 조금 이해한 내가 생각나는 요소들만 이 정도다. 애초에 공정한 비교는 불가능한 것일지도 모르겠다.</p>

<hr />

<p>또 분량 조절에 실패하고 엄청나게 긴 글을 써버리고 말았다. 분명 마크 스윕 컬렉션 알고리즘 얘기를 끄적거리고 있었는데 어쩌다가 이렇게 길어진거지… 아무튼 여기까지는 <strong>싱글 프로세서</strong>에서의 가비지 컬렉션 얘기였다. 다음에는 좀더 구체적으로 OCaml에서 어떤 식으로 구현되어 있는지, 그리고 2026년 현재 가장 최신의 <strong>병렬</strong> 가비지 컬렉션은 어떤 방식으로 동작하는지 살펴보고 싶다.</p>

<p><br /></p>

<p>글을 적는데 참고한 것들:</p>
<ul>
  <li><a href="https://signalsandthreads.com/memory-management/">Memory Management</a> with Stephen Dolan</li>
  <li><a href="https://www.cs.cmu.edu/~fp/courses/15411-f08/misc/wilson94-gc.pdf">Uniprocessor Garbage Collection Techniques</a> by Paul R. Wilson (CMU)</li>
  <li><a href="https://web.media.mit.edu/~lieber/Lieberary/GC/Realtime/Realtime.html">A Real-Time Garbage Collector Based on the Lifetimes of Objects</a> by Henry Lieberman and Carl Hewitt</li>
  <li><a href="https://people.cs.umass.edu/~emery/classes/cmpsci691s-fall2004/papers/p157-ungar.pdf">Generation Scavenging: A Non-disruptive High Performance Storage Reclamation Algorithm</a> by David Ungar</li>
  <li><a href="https://web.eecs.umich.edu/~weimerw/2008-415/reading/bacon-garbage.pdf">A Unified Theory of Garbage Collection</a> by David F. Bacon et. al (IBM Watson)</li>
  <li><a href="https://iecc.com/gclist/GC-faq.html">GC FAG – draft</a></li>
  <li><a href="https://prl.korea.ac.kr/papers/fse18.pdf">MemFix: Static Analysis-Based Repair of Memory Deallocation Errors for C</a></li>
</ul>

<hr />

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:2">
      <p>컴퓨터 과학의 한계를 증명한 정리 중 하나인 <a href="https://en.wikipedia.org/wiki/Rice%27s_theorem">라이스의 정리</a>에 따르면, 프로그램의 모든 non-trivial semantic 성질은 결정 불가능하다. 그래서 Rust의 경우 언어의 표현력을 제한해서 이 문제를 결정 가능한 수준으로 축소해서 풀고, 컴파일러가 추론이 불가능하면 프로그래머에게 명시적인 라이프타임 어노테이션을 요구한다. <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:10">
      <p>엉클 밥의 “읽기와 쓰이게 소요되는 시간의 비율은 10:1을 훨씬 상회한다” 라던지, 귀도 반 로썸의 “코드는 쓰이는 것보다 더 자주 읽힌다” 라던지. <a href="#fnref:10" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:4">
      <p>정확히는, 이렇게 찾은 객체들을 전체 메모리에서 빼고 남은 메모리를 가비지로 간주할 수 있다. <a href="#fnref:4" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:11">
      <p>적은 수의 비트만을 사용해 참조 횟수에 대한 정확도를 희생해서 더 보수적인 근사치를 계산하는 최적화 방법이 연구되기도 했다. <a href="#fnref:11" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:3">
      <p>하지만 레퍼런스 카운팅 “알고리즘” 자체는 유용하게 널리 쓰이는 편이다. 예를 들면, C++의 스마트 포인터나 유니크 포인터가 그러하고, 혹은 정말로 이런 식으로 밖에 관리할 수 없는 외부 리소스들, 예를 들어 파일 식별자에 대해서 자동으로 <code class="language-plaintext highlighter-rouge">open</code>/<code class="language-plaintext highlighter-rouge">close</code> 쌍을 호출할 때 쓰이기도 한다. <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:5">
      <p>사실 이 문제는 마크 스윕에만 한정된 문제는 아니고 대부분의 힙 관리 방법에 공통된 문제다. <a href="#fnref:5" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:21">
      <p>삼색 조합 자체는 당시 다익스트라 등에 의해 처음으로 발표되었다. <a href="#fnref:21" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:8">
      <p>이후 David Ungar의 후속 연구에 의하면, 실제 프로그램의 메모리 사용 패턴을 분석해보니 언어나 프로그램에 상관없이 대부분(80-98%)의 객체는 잠깐만 살아 있다가 사라지고 일부 객체들만이 오래 살아남는고 한다. <a href="#fnref:8" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>sangwoo-joh</name></author><category term="cs" /><category term="essay" /><summary type="html"><![CDATA[목차]]></summary></entry><entry><title type="html">되돌아보는 2025년</title><link href="https://blog.untyped.kr/2025-recap" rel="alternate" type="text/html" title="되돌아보는 2025년" /><published>2025-12-30T00:00:00+00:00</published><updated>2025-12-30T00:00:00+00:00</updated><id>https://blog.untyped.kr/2025-recap</id><content type="html" xml:base="https://blog.untyped.kr/2025-recap"><![CDATA[<p>원래 이런 회고스러운 글은 그다지 좋아하지 않는 편인데 올해는 원체 다사다난했던 터라… 모든 걸 다 적을 순 없지만 그래도 기록을 위해 남겨본다.</p>

<h2 id="영국-생활">영국 생활</h2>
<p>벌써 영국에 온지 만 1년 4개월 정도가 되었다. 여러가지 어렵고 복잡한 일이 있었지만 아무튼 잘 해결되었다. 메데타시 메데타시.</p>

<p>가족들도 잘 적응한 편이다. 특히 아들은 좀더 신체 활동을 즐기게 되어서 기쁘다. 영국에 오게 되었을 때 사실 영어보다는 내심 아들이 운동이나 스포츠에 재미를 붙였으면 하는 기대가 있었다. 나는 내 스스로 운동 신경이 있는 편이지만 학창시절 운동을 하지 못해서 이 모양이 된 거라고(?) 생각하는 편이고, 와이프는 와이프 나름대로 본인의 운동 신경을 높게 평가하지만 증거가 없던 터였다. 그 와중에 한국에서의 아들은 신체 활동을 꺼려하고 뒹굴거리는 걸 좋아해서 걱정이 있었다. 하지만 영국에 오니 과연 축구에 미친 나라 답게 학교 친구, 공원에서 만난 친구, 캠프에서 만난 친구 할 거 없이 모두가 풋볼로 대동단결되는 모습이라 아들도 덩달아 같이 축구를 엄청 좋아하게 되었다. 거기다 내 콩깍지일 수 있겠지만 곧잘 하는 것 같다. 이렇게 아들 바보가 되는가 보다.</p>

<p>심지어 나 개인적으로는 영국의 생활이 꽤나 마음에 든다.</p>
<ul>
  <li>우중충하지만 꽤나 일관된 날씨. 겨울엔 많이 흐리긴 하지만 한국처럼 다이나믹하게 덥고 춥진 않아서 나에게는 꽤 쾌적한 날씨다. 그리고 난 비오는 날씨가 좋다. 다만 여름에 해가 너무 긴 것은 아직 좀 버겁다. 겨울에 해가 짧은 건 오히려 적응하니 괜찮은 편이다.</li>
  <li>사람들. 그동안 영국 국내와 유럽 여기저기 유명한 곳을 꽤나 싸돌아다녔는데, 그때마다 내린 결론은 영국 사람들 정도면 정말 양반이라는 것이다. 왜 영국이 스스로의 문화와 에티켓(예: queuing)을 자랑스러워 하는지 알 것 같았다. 적당한 거리감으로 반겨주는 이웃들도 좋다.</li>
  <li>예측 가능한 시스템. 물론 한국의 속도에 비할 바는 아니지만, 좀 (때로는 많이) 느린 행정 처리 속도를 제외하면 나름의 질서와 체계가 잘 잡혀 있어서 크게 스트레스가 없는 편이다. 물론 운 나쁘게 여러가지가 겹치면 (예: 연휴에 당장 써야만 하는 무언가가 박살이 났다던지) 좀 짜증나겠지만, 미리미리 대비를 해두면 된다. 속도보다는 “언젠가 되긴 한다”는 예측 가능한 사실이 좀더 중요한 것 같다.</li>
  <li>남서부 런던에서의 한적한 삶. 사람들이 생각하는 “런던”의 호화롭고 도회적인 삶은 센트럴 런던의 모습이다. 반면 내가 지내고 있는 남서부 런던은 그린벨트의 원산지 답게 그린벨트로 묶인 곳이 많아서 대자연이 어우러져있다. 그래서 조용하고 고즈넉하게 지내는데 좀 심심하긴 해도 이게 참 좋다. 무엇보다 지근거리에 잔디가 잔뜩 깔린 공원이 많아서 축구공 하나 들고 아들이랑 놀아주러 가기 좋다.</li>
  <li>청교도적인 분위기. 역시 청교도의 원산지라 그런지 잘 설명할 순 없지만 사람들의 삶에서 청교도적인 느낌이 물씬 풍긴다. 학교에서 생각보다 다양한 걸 가르치고, 개인주의적인 듯 하면서도 생각보다 공동체 의식이 느껴지고, 개인의 자유를 존중하지만 동시에 그만큼 책임을 묻고, 꽤나 금욕적인 부분도 있고. 뭐라고 콕 집어서 설명하긴 어려운데 그동안 어렴풋하게 책으로만 접하던 것을 동시대를 살아가는 사람과 환경을 통해서 직접 체험해보니 나쁘지 않았다.</li>
  <li>특유의 풍자적인 유머 (Sarcasm). 진짜로 일상에서 좀 비꼬듯이 유머를 말하는 사람들이 있다. 취향에 맞다면 소소한 일상에 즐거움을 더해준다.</li>
  <li>아이에 대한 배려. 어느 곳을 가든 아이를 위한 것들이 준비되어 있다. 식당에는 키즈 메뉴와 더불어 항상 색칠 공부가 마련되어 있다. 아이와 같이 가면 패스트 트랙으로 해주는 곳이 많다. 공공장소에서 아이들이 크게 떠들고 울어도 사람들은 너그럽게 이해를 해주고 그들의 부모는 아이를 훈육한다.</li>
</ul>

<p>물론 마음에 들지 않는 부분들도 많다. 예를 들면 비싼데 맛 없기까지 한 음식이나, 비싼데 품질도 별로인 공산품이나, 비싼데 느린 프라이빗 서비스같은 것들. 하지만 직접 나와서 살아보기 전에 걱정했던 것만큼 살지 못할 곳은 아니다. 오래 지내도 괜찮을 것 같다.</p>

<h2 id="취미-작업">취미 작업</h2>
<p>올해 취미로 하고 싶었던 코딩 작업을 많이 하지 못해서 아쉽다. 특히 OCaml을 이용한 무언가를 만들고 싶었는데 이 “무언가”에 대한 아이디어가 올해 중순 즈음에 갑작스레 떠올라서 호기롭게 시작은 했지만 초반부터 너무 거대한 그림을 그려버려서인지 갈팡질팡 하고 있다. 요구사항을 적절히 쪼개서 작게 굴려봐야겠다.</p>

<p>반면 올해는 글을 많이 썼다. <a href="https://www.orgroam.com/">Org-roam</a> 기반의 Brain Dump에 쌓인 노드가 이제 787개나 되는데, 그 중 632개를 올해 작성했다. 이건 공부도 할 겸 대부분 영어로 썼는데 평균적으로 약 1,000 단어의 글을 작성했다. 신기하게도 잘 쓴 글을 따라 쓰는 행위 자체가 명상과 비슷하다는 느낌을 받았다. 다만 아직 그래프의 연결이 강하진 않아서 내년에는 쓴 노드들을 읽으면서 연결하는 것도 같이 해야겠다.</p>

<p>엔지니어링 관련 주제들을 정리하는 글을 쓰다가 우연찮게 시리즈물 비슷하게 된 이야기 (?) 작업은 꽤 마음에 든다. 구글 애널리틱스에 따르면 이 시리즈를 시작한 이후로 중국, 싱가폴, 한국에서의 유입이 늘었다. 별 것 아닌 내 글을 찾아 봐주는 사람이 있다는 게 참 감사한 일이다. 근데 이거 내가 만족할 만큼 주제를 이해하고 내가 만족할 만큼 재밌는 글을 쓰는데 생각보다 시간이 너무 많이 소요되어서 어디까지 할 수 있을진 잘 모르겠다. 그래도 아직 정리하고 싶은 토픽이 잔뜩 쌓여있어서 당분간은 계속 되지 않을까 싶다. 원래도 연말까지 쓰려고 준비 중인 주제가 두 개는 더 있는데 생각보다 시간이 안난다. 아무튼 하고 싶은 일이 있다는 것은 좋은 일이다.</p>

<h2 id="통계">통계</h2>
<p>2년 전에 한번 <a href="../short-writing">글 통계</a>를 낸 적이 있었는데 그게 생각나서 올해에도 해보았다. 그때와는 달리 이번엔 전체 누적이 아니라 올해의 포스팅만 세도록 했다. 그리고 단어 수랑 글자 수가 헷갈려서 좀 찾아보니 한국어로 쓴 글은 주로 (공백을 포함한) 글자 수를 세는 것이 일반적인 것 같아서 명칭을 좀 바꾸었다.</p>

<p><img src="assets/img/hist-2025.svg" alt="2025년 통계" /></p>

<p>2025년은 (이 글을 제외하면) 총 11개의 글을 썼고, 평균 7,400자, 중위값은 약 3,800자 정도의 글을 썼다. <a href="../kingdom-of-the-winds">가장 짧은 글</a>은 1,094자 이고 <a href="../performance-engineering-matrix-multiplication">가장 긴 글</a>은 무려 31,520자를 썼다. 분명 마지막 통계를 냈을 때의 주제가 “짧은 글 쓰기”였는데 그때에 비해 평균은 3,400자, 중위값은 1,000자 정도를 더 써버린 한해였다. 아무래도 올해는 여러가지 일이 있다보니 글을 쓰는데 욕심이 나서 이것저것 자료를 준비하다보니 글이 길어진 느낌이 있다. 그래도 얼추 한 달에 글 하나는 쓴 것 같네.</p>

<p><img src="assets/img/monthly-hist-2025.svg" alt="2025년 월 통계" /></p>

<p>월간 통계도 계산해봤다. 아예 글을 안 쓴 달이 네 달이나 되는구나. 하반기에 집중적으로 글을 몰아서 써버렸다. 차마 여기서 말할 수 없는 어떤 이유로 인해 하반기에 꽤 여유가 생긴 덕분이 크다. 덕분에 생각을 정리하는데 큰 도움이 되었다.</p>

<p>역시 거창한 인사이트를 얻으려는 것은 아니고 할 수 있어서 해봤는데, 하고 보니 매 달 3,000-5,000자 분량의 글을 쓰는 것을 목표로 하면 좋을 것 같다는 생각이 문득 들었다. 주제는 늘 그렇듯 뭐든 내가 이해한 것으로 해서.</p>

<h2 id="다음-장">다음 장</h2>
<p>앞으로 어떻게 살면 좋을지 말 못할 고민이 많다. 다들 머릿 속에 고민의 소용돌이를 안고 살아 가는건가? 아니면 나만 이런건가? 뉴럴링크가 발달하면 이런 것도 통계를 낼 수 있으려나.</p>

<p>그래도 잘 하고 싶다는 욕심은 아직 남아있다. 개인적으로 큰 울림을 받았던 이동진 평론가의 삶의 태도에 대한 글이 있는데,</p>

<blockquote>
  <p>자연 과학에서 프랙탈이라는 게 있습니다. 프랙탈이 뭔가 하면, 나무의 작은 가지를 하나 꺾어 세워 보면 그게 큰 나무의 형태랑 같다는 거에요. 혹은 해안선에서 1센치쯤 되는 부분을 아주 크게 확대하면, 전체 해안선의 모습과 비슷하다는 거에요. 다시 말해서, 부분이 전체의 형상을 반복한다는 말을 프랙탈이라고 해요. 저는 인생도 정말 프랙탈이라고 생각해요. 예를 들어서 지금 천사가 있고, 천사가 어떤 한 사람의 일생을 판가름한다고 생각해 보세요. 그 사람의 일생을 처음부터 다 보면 좋겠지만, 천사는 바쁘니까 그렇게 하지 못하는 상황이라고 할게요. 그럼 어떻게 하느냐? 천사는 아무 단위나 고르는 겁니다. 예를 들어 그게 저라고 한다면, 저의 2008년 어느 날을 고르는 겁니다. 그리고 그 24시간을 천사가 스캐닝한다고 생각해 보세요. 그날 제가 누구한테 화를 낼 수도 있고, 그날따라 일을 잘 해서 상을 받았을 수도 있죠. 어찌 됐건 그 24시간을 천사가 본다면, 이걸로 그 사람의 일생을 판단할 확률이 95%는 될 것 같아요. 무슨 말인가 하면, 성실한 사람은 아무리 재수 없는 날도 성실합니다. 성실하지 않은 사람은 수능 전 날이라고 할지라도 성실하지 않습니다. 제가 드리고 싶은 얘기는, 이렇게 하루하루가 모여서 인생이 만들어지는 거지, 인생에 거대한 목표가 있고 그것을 위해 매진해가는 것이 아니라는 거죠. 제 인생 블로그에 대 문구가 있습니다. “하루하루는 성실하게, 인생 전체는 되는대로.” 이렇게 생각했던 이유는 우리가 인생 전체를 플래닝할 수 없기 때문입니다. 그럼 이렇게 변화도 많고, 우리를 좌절시키는 일 투성이인 인생에서 어떻게 해서 그나마 실패 확률을 줄일 것인가? 그것은 하루하루 성실하게 사는 것 밖에 없다는 거죠.</p>
</blockquote>

<p>하루하루는 성실하게, 인생 전체는 되는대로. 점차 불확실해지는 세상 속에서 내가 지킬 수 있는 태도는 이것 뿐이다.</p>]]></content><author><name>sangwoo-joh</name></author><category term="life" /><summary type="html"><![CDATA[원래 이런 회고스러운 글은 그다지 좋아하지 않는 편인데 올해는 원체 다사다난했던 터라… 모든 걸 다 적을 순 없지만 그래도 기록을 위해 남겨본다.]]></summary></entry><entry><title type="html">LLM과 버프</title><link href="https://blog.untyped.kr/llm-and-buff" rel="alternate" type="text/html" title="LLM과 버프" /><published>2025-11-21T00:00:00+00:00</published><updated>2025-11-21T00:00:00+00:00</updated><id>https://blog.untyped.kr/llm-and-buff</id><content type="html" xml:base="https://blog.untyped.kr/llm-and-buff"><![CDATA[<p>옛날에 게임에서 레이드 뛸 때 중요한 일 중 하나는 버프를 챙기는 일이었다. 직업마다 필요한 버프의 종류가 달랐고, 그 버프를 위해서 준비해야 하는 난이도도 달랐다. 뭐가 됐든 순서에 맞게 올바른 버프를 잘 쌓으면 캐릭터의 성능이 크게는 10배까지 뛰었다. 10x 엔지니어는 현실에 없지만, 10x 캐릭터는 충분히 가능했다. 다만 그 범위는 제한적이었고 항상 가능한 것도 아니었다. 어떤 캐릭터는 스킬만 딸깍 하면 최대치의 버프를 손쉽게 얻을 수 있었지만 어떤 캐릭터는 특정 조건에서 순서에 맞게 꾸역꾸역 버프를 쌓아야 했고 그마저도 효율적이지 못하기도 했다. 어떤 버프는 특정 직업의 특정 스킬을 엄청나게 효율적으로 강화해주지만, 다른 직업에게는 효과가 없거나 심지어는 역효과를 내기도 했다. 어떤 버프가 어떤 역할을 하는지 잘 살펴봐야 했다. 또, 버프는 가격이 비쌌기 때문에 내가 필요한 버프가 무엇인지 미리 계획을 짜두어야 했다. 일종의 공부가 필요했다. 그리고 디버프도 있었다. 보통은 적을 약하게 하거나 상대의 방어를 무력하게 만들어 나의 데미지를 더 높이는 방향으로 작용했다. 하지만 어떤 경우에는, 예를 들어 특정 상황에서만 가능한 버프 또는 확률적으로 성공 가능한 버프가 실패하는 경우에는, 일종의 패널티 개념으로 다양한 종류의 디버프가 스스로에게 걸리기도 했다.</p>

<p>문득 LLM이 버프와 비슷하다는 생각을 했다.</p>

<p>솔직히 LLM 덕분에 지적 업무의 부하가 크게 줄었다. 21세기 직장인은 글을 읽고 쓰는 일에서 벗어날 수 없는데, 그런 측면에서 LLM은 글의 의미를 이해하고, 상황에 맞게 글을 다듬고, 메일의 톤을 적절하게 살려내는 등의 작업에서 강력한 버프가 된다. LLM은 또 코드와 관련된 작업도 가볍게 해준다. 코드도 역시 텍스트이고, LLM은 이걸 충분히 많이 학습했기 때문이다. 커다란 코드 베이스에서 익숙하지 않은 모듈의 의미를 이해하고, 단순하지만 반복적인 코드를 짜고, 많이 알려진 언어의 많이 알려진 패턴을 적용하고, 이미 널리 알려진 알고리즘을 작성하는 등의 작업에서 강력한 버프 효과를 볼 수 있다.</p>

<p>그런데 여기서 갭이 생긴다.</p>

<p>LLM이 모든 일을 가능하게 해주는 것은 아니다. 내가 하려고 하는 일이 뭔지, 어느 방향으로 나아가려고 하는지 먼저 알아야 한다. 그래야 LLM의 결과가 정말 내가 원하는 것인지를 판단할 수 있다. 그리고 LLM의 한계가 어디까지인지도 파악해야만 한다. 얼마나 큰 덩어리의 일을 시킬 수 있는지, 추론 모델의 경우 어느 정도로 추상화된 프롬프트를 던져줘야 하는지 등을 파악해야 내가 원하는 일을 할 수 있다. 하지만 이게 모든 분야에서, 모든 사람에게 쉬운 일은 아니다.</p>

<p>텍스트는 무한한 분야를 표현할 수 있고, LLM은 자신감 있게 틀린 대답을 내놓을 수 있다. 어떤 개발자는 LLM이 뱉은 코드 한 줄을 보고 요구사항에 맞게 짠건지 아니면 지금 얘가 <a href="https://en.wikipedia.org/wiki/On_Bullshit">헛소리</a>를 하고 있는지 빠르게 알 수 있지만, 다른 분야의 사람에게는 알기 어려울 것이다.  게다가 많은 연구에서 지적하듯이 현대 LLM의 본질은 텍스트를 예측하는 것이기 때문에, 학습한 내용에 따른 편향(bias)을 완전히 피할 수는 없다. 그래서 모델마다 독특한 편향을 가지고 있다. 어떤 모델은 코드를 좀더 보수적으로 작성하기도 하고, 특정 언어의 특정 프레임워크의 방법론만을 고집하기도 한다. 심지어 어떤 모델은 특정 정치 성향을 거리낌없이 드러내기도 한다. 그러니까, 잘못 가져다 쓰면 디버프가 걸리는 것이다. 은탄환(Silver Bullet)이 없는 것처럼, 은모델(?)도 없다.</p>

<p>결국 내 상황과 맥락을 정확하게 이해하고, 해야 할 일이 뭔지 파악하고, 각 모델의 특징을 파악한 뒤에 LLM을 활용하는 사람에게는, 마치 게임에서 순서대로 필요한 버프를 완벽하게 쌓아 올라 10배 이상의 데미지를 볼 수 있는 캐릭터처럼 엄청난 생산성 향상이 가능하다. 반대로 이런 것들을 고려하지 않고 무작정 잘 되길 바라면서 가져다 써버리면 아무런 효과가 없는 버프로 남거나 심지어는 틀린 대답으로 인한 디버프로 음의 생산성을 가지게 될 지도 모른다. 여기서 양극화가 발생한다.</p>

<p>그리고 나는 이 지점에서 기회가 생길거라고 생각한다.</p>

<p>LLM이 발전할수록, 오히려 글을 읽고 쓰는 능력은 더욱더 중요해질 거라고 본다. 손쉽게 얻을 수 있는 디지털 도파민이 넘쳐나는 시대에서 주의를 잃지 않고, 글을 깊게 이해하고 자기 생각을 명징하게 글로 표현할 줄 아는 능력은 점점 희귀해질 것이다. 자라나는 아이들 뿐만 아니라 어른들도 이걸 의식적으로 연습하지 않으면 어느 순간 디버프에 걸린 나 자신을 발견하게 될 지도 모르겠다.</p>]]></content><author><name>sangwoo-joh</name></author><category term="musing" /><summary type="html"><![CDATA[옛날에 게임에서 레이드 뛸 때 중요한 일 중 하나는 버프를 챙기는 일이었다. 직업마다 필요한 버프의 종류가 달랐고, 그 버프를 위해서 준비해야 하는 난이도도 달랐다. 뭐가 됐든 순서에 맞게 올바른 버프를 잘 쌓으면 캐릭터의 성능이 크게는 10배까지 뛰었다. 10x 엔지니어는 현실에 없지만, 10x 캐릭터는 충분히 가능했다. 다만 그 범위는 제한적이었고 항상 가능한 것도 아니었다. 어떤 캐릭터는 스킬만 딸깍 하면 최대치의 버프를 손쉽게 얻을 수 있었지만 어떤 캐릭터는 특정 조건에서 순서에 맞게 꾸역꾸역 버프를 쌓아야 했고 그마저도 효율적이지 못하기도 했다. 어떤 버프는 특정 직업의 특정 스킬을 엄청나게 효율적으로 강화해주지만, 다른 직업에게는 효과가 없거나 심지어는 역효과를 내기도 했다. 어떤 버프가 어떤 역할을 하는지 잘 살펴봐야 했다. 또, 버프는 가격이 비쌌기 때문에 내가 필요한 버프가 무엇인지 미리 계획을 짜두어야 했다. 일종의 공부가 필요했다. 그리고 디버프도 있었다. 보통은 적을 약하게 하거나 상대의 방어를 무력하게 만들어 나의 데미지를 더 높이는 방향으로 작용했다. 하지만 어떤 경우에는, 예를 들어 특정 상황에서만 가능한 버프 또는 확률적으로 성공 가능한 버프가 실패하는 경우에는, 일종의 패널티 개념으로 다양한 종류의 디버프가 스스로에게 걸리기도 했다.]]></summary></entry><entry><title type="html">성능 엔지니어링 - 행렬 곱셈 이야기</title><link href="https://blog.untyped.kr/performance-engineering-matrix-multiplication" rel="alternate" type="text/html" title="성능 엔지니어링 - 행렬 곱셈 이야기" /><published>2025-11-18T00:00:00+00:00</published><updated>2025-11-18T00:00:00+00:00</updated><id>https://blog.untyped.kr/performance-engineering-matrix-multiplication</id><content type="html" xml:base="https://blog.untyped.kr/performance-engineering-matrix-multiplication"><![CDATA[<h2 class="no_toc" id="목차">목차</h2>

<ul id="markdown-toc">
  <li><a href="#성능" id="markdown-toc-성능">성능</a></li>
  <li><a href="#행렬-곱셈-문제" id="markdown-toc-행렬-곱셈-문제">행렬 곱셈 문제</a>    <ul>
      <li><a href="#실험할-하드웨어" id="markdown-toc-실험할-하드웨어">실험할 하드웨어</a></li>
      <li><a href="#버전-1-파이썬" id="markdown-toc-버전-1-파이썬">버전 1: 파이썬</a></li>
      <li><a href="#버전-2-자바" id="markdown-toc-버전-2-자바">버전 2: 자바</a></li>
      <li><a href="#버전-3-c" id="markdown-toc-버전-3-c">버전 3: C</a></li>
      <li><a href="#버전-4-반복문-중첩-순서-바꾸기" id="markdown-toc-버전-4-반복문-중첩-순서-바꾸기">버전 4: 반복문 중첩 순서 바꾸기</a></li>
      <li><a href="#버전-5-컴파일러-최적화" id="markdown-toc-버전-5-컴파일러-최적화">버전 5: 컴파일러 최적화</a></li>
      <li><a href="#버전-6-병렬-처리" id="markdown-toc-버전-6-병렬-처리">버전 6: 병렬 처리</a></li>
      <li><a href="#버전-7-메모리-접근-최적화" id="markdown-toc-버전-7-메모리-접근-최적화">버전 7: 메모리 접근 최적화</a></li>
      <li><a href="#버전-8-병렬로-분할정복" id="markdown-toc-버전-8-병렬로-분할정복">버전 8: 병렬로 분할정복</a></li>
      <li><a href="#버전-9-vectorization" id="markdown-toc-버전-9-vectorization">버전 9: Vectorization</a></li>
      <li><a href="#버전-10-avx-intrinsics" id="markdown-toc-버전-10-avx-intrinsics">버전 10: AVX Intrinsics</a></li>
      <li><a href="#버전-11-인더스트리-라이브러리" id="markdown-toc-버전-11-인더스트리-라이브러리">버전 11: 인더스트리 라이브러리</a></li>
      <li><a href="#정리-및-최종-결론" id="markdown-toc-정리-및-최종-결론">정리 및 최종 결론</a></li>
    </ul>
  </li>
</ul>

<p>발단: 평소처럼 재미있는 글이 없나 하고 <a href="https://news.hada.io/">긱뉴스</a>를 탐방하다가 <a href="https://rona.substack.com/p/becoming-a-compiler-engineer">컴파일러 엔지니어가 되는 법</a>이라는 글을 보게 되었다. 한국에서만 컴파일러 관련 포지션이 적은건가 싶었는데 미국도 상황은 비슷한가보구나, 라고 생각하면서 읽다가, 저자의 추천으로 MIT OCW의 <a href="https://ocw.mit.edu/courses/6-172-performance-engineering-of-software-systems-fall-2018/">Performance Engineering</a> 강의를 만나게 되었다. 마침 최근 내 관심사와 맞닿아 있는 주제라서 강의 슬라이드를 보고 있는데 너무 재밌어서 내 식으로 소화도 할 겸 좀 정리해보려고 한다. 마침 <a href="https://ocw.mit.edu/pages/privacy-and-terms-of-use/">MIT OpenCourseWare 라이센스</a>도 출처만 표기한다면 너그러운 편이다.</p>

<p><a href="https://ocw.mit.edu/courses/6-172-performance-engineering-of-software-systems-fall-2018/resources/lecture-1-introduction-and-matrix-multiplication/">1강</a>은 성능 엔지니어링 전반에 대한 이야기를 시작으로, 행렬 곱셈을 어디까지 최적화할 수 있는지에 대한 이야기이다.</p>

<h2 id="성능">성능</h2>
<p>소프트웨어를 만들 때는 성능보다 더 중요하게 고려해야 하는 속성들이 있다. 호환이 잘 되는지, 올바르게 동작하는지, 원하는 기능이 잘 동작하는지, 신뢰도는 어떤지, 코드는 분명한지, 유지보수는 얼마나 용이한지, 다른 모듈과의 조립은 얼마나 용이한지, 이식성은 좋은지, 테스트하기 좋은지, 사용성이 좋은지, 디버깅하기 좋은지, 강건한지(Robustness) 등등.</p>

<p>성능은 이러한 속성을 “살 수 있는” 일종의 화폐라고 볼 수 있다. 즉, 우리는 성능을 희생해서 유지보수성을 높일 수 있고, 성능을 희생해서 디버깅하기 좋은 코드를 만들 수 있고, 성능을 희생해서 모듈성을 높일 수 있다. 그러니까 소프트웨어 엔지니어링의 모든 분야가 늘 그렇듯 균형(Trade-Off)을 고려해야만 한다.</p>

<p>아주 초창기 컴퓨팅 하드웨어의 파워가 빈약했던 시절에는 하드웨어의 비용 문제도 있었지만, 일정 수준의 성능이 받쳐주지 않으면 애초에 프로그램 자체를 돌릴 수 없는 경우도 많았다. 그래서 이 시절에는 앞서 말한 소프트웨어의 다양한 중요한 속성들을 많이 희생하면서 성능에 집중하기도 했다. 그러다보니 섣부른 최적화와 관련해서 많은 교훈들이 알려져 있다.</p>

<blockquote>
  <p>Premature optimization is the root of all evil. - Donald Knuth</p>
</blockquote>

<p>아마도 도널드 크누스의 이 문장이 가장 유명할 것이다. 섣불리 최적화하면 큰일난다.</p>

<blockquote>
  <p>More computing sins are committed in the name of efficiency (without necessarily achieving it) than for any other single reason - including blind stupidity. - William Wulf</p>
</blockquote>

<p>이 말은 72년에 ACM 컨퍼런스에서 발표된 논문 “A Case Against the GOTO” 에서 나온 말이다. 교과서에서 배웠던 “GOTO 쓰지마세요”를 주장한 논문이라고 한다. 실제로 성능 개선을 이루지 못하면서도 효율성이라는 이름 하에 저질러지는 나쁜 프로그래밍 관행이 아주 많으니, 효율성을 과도하게 쫓지 말라는 경고를 담고 있다.</p>

<blockquote>
  <p>(Jackson’s Rules of Optimization) The first rule of program optimization: Don’t do it. The second rule of program optimization - for experts only: don’t do it yet. - Michael Jackson</p>
</blockquote>

<p>마이클 잭슨은 낚시가 아니라 영국의 컴퓨터 과학자 <a href="https://en.wikipedia.org/wiki/Michael_A._Jackson_(computer_scientist)#:~:text=Michael%20Anthony,the%20UK.">마이클 A. 잭슨</a>이다. 잭슨의 최적화 규칙으로 알려진 이 말은 초기의 과도한 최적화가 코드의 복잡성을 높이고 버그를 유발하니까 되도록 피하고 나중에 필요한 부분만 최적화하라는 교훈을 담고 있다.</p>

<p>그런데 무어의 법칙에 따라 시간이 지나면서 하드웨어, 특히 단일 칩의 성능이 비약적으로 발전하기 시작했다. 비유하자면 성능이라는 화폐를 마구 찍어내는 시기였다. 하지만 그것도 2004년까지였고, 그 이후에는 물리적인 한계로 인해 프로세서의 클럭 스피드가 예전만큼 극적으로 증가하지는 않게 되었다.</p>

<p>그래서 하드웨어 제조사들은 단일 칩의 성능을 개선하는 것에서 벗어나서 여러 개의 코어를 탑재하는 쪽으로 눈을 돌렸다. 성능의 규모를 늘리기 위해서 (scale), 여러 개의 코어를 마이크로 프로세서 칩에 박기 시작한 것이다. 물론 단일 칩의 성능도 꾸준히 좋아지고는 있지만, 이제 옛날처럼 성능이라는 화폐를 마구 찍어낼 수는 없다. 더 이상 성능은 공짜가 아니다.</p>

<p>현대의 멀티코어 프로세서는 그 이름대로 여러 개의 병렬 처리 코어를 담고, 복잡한 캐시 구조와, 병렬 처리를 위한 벡터 하드웨어와, 프리 페처와, 하이퍼쓰레딩, 등등 많은 것들을 탑재하기 시작했다. 그리고 이런 복잡한 하드웨어의 성능을 최대한 이끌어내려면 <strong>소프트웨어를 반드시 거기에 맞춰야 한다</strong>. 즉, 성능을 위한 엔지니어링이 필요하다.</p>

<p>당연하지만 이런 성능 엔지니어링은 어렵다. 어떻게 하면 현대의 하드웨어를 효과적으로 활용할 수 있는 소프트웨어를 짤 수 있을까? 이것이 바로 성능 엔지니어링의 핵심 질문이다.</p>

<h2 id="행렬-곱셈-문제">행렬 곱셈 문제</h2>
<p>성능 엔지니어링을 통해서 현대의 멀티코어 하드웨어의 성능을 어디까지 끌어낼 수 있을지, 행렬 곱셈 문제를 중심으로 살펴보자. 여기서는 다음과 같은 단순한 곱셈 식을 중심으로 살펴볼 것이다.</p>

\[\begin{bmatrix}
c_{11} &amp; c_{12} &amp; \cdots &amp; c_{1n} \\
c_{21} &amp; c_{22} &amp; \cdots &amp; c_{2n} \\
\vdots &amp; \vdots &amp; \ddots &amp; \vdots &amp; \\
c_{n1} &amp; c_{n2} &amp; \cdots &amp; c_{nn} \\
\end{bmatrix}
=
\begin{bmatrix}
a_{11} &amp; a_{12} &amp; \cdots &amp; a_{1n} \\
a_{21} &amp; a_{22} &amp; \cdots &amp; a_{2n} \\
\vdots &amp; \vdots &amp; \ddots &amp; \vdots &amp; \\
a_{n1} &amp; a_{n2} &amp; \cdots &amp; a_{nn} \\
\end{bmatrix}
\cdot
\begin{bmatrix}
b_{11} &amp; b_{12} &amp; \cdots &amp; b_{1n} \\
b_{21} &amp; b_{22} &amp; \cdots &amp; b_{2n} \\
\vdots &amp; \vdots &amp; \ddots &amp; \vdots &amp; \\
b_{n1} &amp; b_{n2} &amp; \cdots &amp; b_{nn} \\
\end{bmatrix}\]

\[c_{ij} = \Sigma^{n}_{k=1} a_{ik} b_{kj}\]

<p>문제를 심플하게 하기 위해서 \(n = 2^{12} = 4096\) 라고 하자.</p>

<h3 id="실험할-하드웨어">실험할 하드웨어</h3>
<p>실험하기 좋은 세상이다. 클라우드가 도처에 널려있다. 가장 유명한 AWS의 c4.8xlarge 머신의 스펙은 다음과 같다. (아마도 옛날 기준인듯 하다, 지금은 스펙이 다를듯)</p>

<table>
  <thead>
    <tr>
      <th>상세</th>
      <th>스펙</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>마이크로아키텍쳐</td>
      <td>하스웰 (인텔 제온 E5-2666 v3)</td>
    </tr>
    <tr>
      <td>클럭 속도</td>
      <td>2.9 GHz</td>
    </tr>
    <tr>
      <td>프로세서 칩 수</td>
      <td>2</td>
    </tr>
    <tr>
      <td>프로세싱 코어 수</td>
      <td>프로세서 칩 당 9</td>
    </tr>
    <tr>
      <td>하이퍼스레딩</td>
      <td>2-way</td>
    </tr>
    <tr>
      <td>부동 소수점 유닛</td>
      <td>각 코어가 한 사이클마다 8개의 배정밀도 연산 가능</td>
    </tr>
    <tr>
      <td>캐시 라인 크기</td>
      <td>64B</td>
    </tr>
    <tr>
      <td>L1 인스트럭션 캐시</td>
      <td>32KB private 8-way set associative</td>
    </tr>
    <tr>
      <td>L1 데이터 캐시</td>
      <td>32KB private 8-way set associative</td>
    </tr>
    <tr>
      <td>L2 캐시</td>
      <td>256KB private 8-way set associative</td>
    </tr>
    <tr>
      <td>L3 캐시 (LLC)</td>
      <td>25MB shared 20-way set associative</td>
    </tr>
    <tr>
      <td>DRAM</td>
      <td>60GB</td>
    </tr>
  </tbody>
</table>

<p>사실 나는 위의 하드웨어 스펙만 봐서는 이게 어떤 의미인지 잘 모르겠어서 조금 더 찾아봤다.</p>
<ul>
  <li>마이크로아키텍쳐: 하스웰은 2013년에 출시했다. 강의 슬라이드 날짜인 2018년 기준으로도 최신은 아니지만, 안정적이고 전력 효율이 좋다고 한다.</li>
  <li>클럭 속도 (Clock Frequency): GHz니까 초당 29억 번 (\(2.9 \times 10^{9}\)) 연산(사이클)을 할 수 있다.</li>
  <li>프로세서 칩 수: 2개의 물리 프로세서 칩이 이 프로세서에 박혀있다는 뜻이다.</li>
  <li>프로세싱 코어 수: 위의 물리 칩 하나 당 9개의 코어가 있다는 뜻이다. 그래서 총 18개의 물리 코어가 있다.</li>
  <li>하이퍼스레딩: 각 물리 코어에서는 최대 2개의 쓰레드를 동시에 실행할 수 있다는 뜻이다. 그래서 총 36개의 가상 코어가 있다.</li>
  <li>부동 소수점 유닛: 원문은 “8 double-precision operations, including fused-multiply-add, per core per cycle” 이라고 되어 있는데, 이거만 봐서는 잘 이해가 안되어서 좀더 찾아봤다. 일단 FMA(Fused-Multiply-Add) 라는 건 곱셈과 덧셈을 한 사이클에 수행하게 해주는 기능이다. 그리고 배정밀도(double-precision)는 64비트의 부동 소수점을 의미한다. “per core per cycle”라는 말이 가장 헷갈렸는데, 앞문장과 합쳐서, 이는 각각의 코어가 한 클럭 사이클마다 최대 8개의 배정밀도 연산이 가능하다는 뜻이다. 참고로 이건 <strong>물리적으로 8개의 FPU가 달려있다는 뜻이 아니다</strong>. 하스웰에는 AVX (Advanced Vector Extensions) 라는 명령어가 제공되는데, 이를 통해 256비트 벡터에 대한 부동 소수점 연산을 한 사이클에 처리할 수 있다. 이걸 배정밀도 기준으로 쪼개면 4개인데, FMA를 통해서 덧셈과 곱셉을 한번에 할 수 있으니, 총 8개의 배정밀도 부동 소수점 연산(그래서 모호하게 <em>연산</em> 이라고 쓴 것 같다)이 가능하다는 의미로 해석된다. 즉, 하드웨어가 제공하는 고급 명령어를 이용하면, 각 코어마다 부동 소수점 연산의 처리량을 한 사이클에 최대 8배 더 할 수 있다는 뜻이다. 어차피 우리는 유닛의 개수가 몇 개인지가 중요한게 아니라 처리량이 중요하기 때문에 이 정보가 더 의미있다.</li>
  <li>캐시 라인 크기: 메모리에서 한 번에 가져오는 데이터 단위이다. 이 크기만큼 캐시에 로드된다.</li>
  <li>L1 캐시들: <a href="../virtual-memory">가상 메모리 이야기</a>의 인트로에서 잠깐 살펴봤듯, 인스트럭션 캐시와 데이터 캐시로 나뉘어져있는 것을 알 수 있다. 32KB는 크기이고, private은 하나의 물리 코어가 독점적으로 하나 씩 갖고 있다는 의미이다. 그래서 총 18개의 L1 인스트럭션 캐시와 18개의 L1 데이터 캐시가 붙어 있다. 8-way set associative는 캐시의 주소 맵핑 방식 중 하나인데, 이건 다음 기회에 조금 더 자세히 살펴보려고 한다. 아무튼 중요한 건 매우 빠르다.</li>
  <li>L2 캐시: 역시 코어마다 전용으로 붙어 있으므로 (private) 총 18개가 있고, 사이즈도 더 크지만, L1보다는 느리다.</li>
  <li>L3 캐시: LLC는 Last Level Cache라는 뜻이다. 즉 여기까지만 캐시가 붙고 이후는 메모리 접근이라서 많이 느리다. shared의 기준은 칩이라서 총 2개의 L3가 붙어 있고 칩 안의 모든 9개 코어가 공유한다.</li>
</ul>

<p>그럼 이 하드웨어 스펙으로부터 뭘 알아 낼 수 있냐면, 이상적인 조건에서 달성 가능한 최대 성능을 계산해볼 수 있다. 특히 요즘 AI로 인해서 화두가 되고 있는 GFLOPS(기가플롭스; 초당 부동 소수점 연산의 수), 그 중에서도 이론적으로 가능한 Peak GFLOPS를 계산할 수 있다.</p>

<ul>
  <li>Peak GFLOPS = 클럭 속도 x 물리 코어 수 x 싸이클 당 부동 소수점 연산 수 x 하이퍼스레딩 팩터</li>
  <li>이를 계산하면 \((2.9 \times 10^9) \times 2 \times 9 \times 16\)로 대략 836 GFLOPS를 얻는다.</li>
</ul>

<p>여기서 하이퍼스레딩 팩터는 하이퍼스레딩 2 way와 8배의 배정밀도 부동소수점 연산 처리량을 모두 고려한 값이다. 클럭은 초당 사이클 수 이고, 8배의 연산 처리량은 코어마다 한 사이클에 최대 8개의 배정밀도 연산이 가능하다는 의미이니 8을 곱하는 데에는 의심의 여지가 없다. 다만 2-way 하이퍼스레딩이라서 2를 곱한 것은 조금 의문이 있다. 왜냐하면 2-way 하이퍼스레딩은 물리적으로 2개의 연산 유닛이 붙어 있다는 뜻이 아니라 두 개의 논리적인 쓰레드를 동시에 실행할 수 있다는 의미라서, Peak GFLOPS를 계산할 때는 물리 코어만 고려해야하지 않나 하는게 내 생각이다. 아주 이상적인 실행 환경에서는 2를 곱하는게 맞겠지만, 실제로는 달성 불가능하지 않을까. 아무튼 강의 슬라이드에서는 2를 곱한 이상적인 값을 기준으로 했으니 일단 여기서도 똑같이 하고자 한다.</p>

<p>그러면 이제 이상적인 최대 성능인 Peak GFLOPS를 기준으로, 각각의 프로그래밍 언어와 방법에 따라서 우리가 얼마나 이 하드웨어를 활용할 수 있는지 살펴보자.</p>

<h3 id="버전-1-파이썬">버전 1: 파이썬</h3>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">sys</span><span class="p">,</span> <span class="n">random</span>
<span class="kn">from</span> <span class="n">time</span> <span class="kn">import</span> <span class="o">*</span>

<span class="n">n</span> <span class="o">=</span> <span class="mi">2</span> <span class="o">**</span> <span class="mi">12</span>

<span class="n">A</span> <span class="o">=</span> <span class="p">[[</span><span class="n">random</span><span class="p">.</span><span class="nf">random</span><span class="p">()</span> <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="n">n</span><span class="p">)]</span> <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="n">n</span><span class="p">)]</span>
<span class="n">B</span> <span class="o">=</span> <span class="p">[[</span><span class="n">random</span><span class="p">.</span><span class="nf">random</span><span class="p">()</span> <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="n">n</span><span class="p">)]</span> <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="n">n</span><span class="p">)]</span>
<span class="n">C</span> <span class="o">=</span> <span class="p">[[</span><span class="mi">0</span> <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="n">n</span><span class="p">)]</span> <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="n">n</span><span class="p">)]</span>

<span class="n">start</span> <span class="o">=</span> <span class="nf">time</span><span class="p">()</span>
<span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="n">n</span><span class="p">):</span>
    <span class="k">for</span> <span class="n">j</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="n">n</span><span class="p">):</span>
        <span class="k">for</span> <span class="n">k</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="n">n</span><span class="p">):</span>
            <span class="n">C</span><span class="p">[</span><span class="n">i</span><span class="p">][</span><span class="n">j</span><span class="p">]</span> <span class="o">+=</span> <span class="n">A</span><span class="p">[</span><span class="n">i</span><span class="p">][</span><span class="n">k</span><span class="p">]</span> <span class="o">*</span> <span class="n">B</span><span class="p">[</span><span class="n">k</span><span class="p">][</span><span class="n">j</span><span class="p">]</span>
<span class="n">end</span> <span class="o">=</span> <span class="nf">time</span><span class="p">()</span>
<span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="si">{</span><span class="n">end</span> <span class="o">-</span> <span class="n">start</span><span class="si">:</span><span class="p">.</span><span class="mi">6</span><span class="n">f</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>
</code></pre></div></div>

<p>앞에서 봤던 행렬 곱셈에 대한 element-wise 식인 \(c_{ij} = \Sigma^{n}_{k=1} a_{ik} b_{kj}\) 를 나이브하게 구현한 파이썬 코드이다. 당연히 순수 파이썬이라서 느리겠지만, 이게 얼마나 느린 걸까?</p>

<p>위의 실험 머신에서 이걸 돌리면 21,042초, 약 6시간 정도가 걸린다고 한다. 우리는 머신의 이론적인 Peak GFLOPS가 836 이라는 사실을 계산했다. 파이썬 코드가 얻은 FLOPS를 러프하게 계산해보자.</p>

<p>먼저 \(n^3\)번의 반복문 안에서 곱셈과 덧셈을 하고 있으니, 총 \(2 \times n^3 = 2 \times (2 ^{12}) ^{3} = 2^{37}\) 번의 부동 소수점 연산을 한다 (FLOP). 그리고 이 연산을 다 하는데 21,042 초가 걸렸으니, 최종 FLOPS는 \(2^{37} / 21042 \approx 6.25 \text{ MFLOPS}\) 가 된다. Peak GFLOPS에 대한 비율을 계산해보면, 이 프로그램은 최대 성능 대비 \(\frac{6.25 \times 10^6}{836 \times 10^9} \approx\) <strong>0.00078%</strong> 밖에 뽑아내지 못한다는 사실을 알 수 있다. 순수 파이썬이 느리다는 사실은 익히 들어 알고 있지만, 이렇게 수치를 통해서 비교해보니 더더욱 처참한 수치이다.</p>

<h3 id="버전-2-자바">버전 2: 자바</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">java.util.Random</span><span class="o">;</span>

<span class="kd">public</span> <span class="kd">class</span> <span class="nc">mm_java</span> <span class="o">{</span>
  <span class="kd">static</span> <span class="kt">int</span> <span class="n">n</span> <span class="o">=</span> <span class="mi">4096</span><span class="o">;</span>
  <span class="kd">static</span> <span class="kt">double</span><span class="o">[][]</span> <span class="no">A</span> <span class="o">=</span> <span class="k">new</span> <span class="n">doulble</span> <span class="o">[</span><span class="n">n</span><span class="o">][</span><span class="n">n</span><span class="o">];</span>
  <span class="kd">static</span> <span class="kt">double</span><span class="o">[][]</span> <span class="no">B</span> <span class="o">=</span> <span class="k">new</span> <span class="n">doulble</span> <span class="o">[</span><span class="n">n</span><span class="o">][</span><span class="n">n</span><span class="o">];</span>
  <span class="kd">static</span> <span class="kt">double</span><span class="o">[][]</span> <span class="no">C</span> <span class="o">=</span> <span class="k">new</span> <span class="n">doulble</span> <span class="o">[</span><span class="n">n</span><span class="o">][</span><span class="n">n</span><span class="o">];</span>

  <span class="kd">public</span> <span class="kd">static</span> <span class="kt">void</span> <span class="nf">main</span><span class="o">(</span><span class="nc">String</span><span class="o">[]</span> <span class="n">args</span><span class="o">)</span> <span class="o">{</span>
    <span class="nc">Random</span> <span class="n">r</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Random</span><span class="o">();</span>

    <span class="k">for</span> <span class="o">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">n</span><span class="o">;</span> <span class="n">i</span><span class="o">++)</span> <span class="o">{</span>
      <span class="k">for</span> <span class="o">(</span><span class="kt">int</span> <span class="n">j</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span> <span class="n">j</span> <span class="o">&lt;</span> <span class="n">n</span><span class="o">;</span> <span class="n">j</span><span class="o">++)</span> <span class="o">{</span>
        <span class="no">A</span><span class="o">[</span><span class="n">i</span><span class="o">][</span><span class="n">j</span><span class="o">]</span> <span class="o">=</span> <span class="n">r</span><span class="o">.</span><span class="na">nextDouble</span><span class="o">();</span>
        <span class="no">B</span><span class="o">[</span><span class="n">i</span><span class="o">][</span><span class="n">j</span><span class="o">]</span> <span class="o">=</span> <span class="n">r</span><span class="o">.</span><span class="na">nextDouble</span><span class="o">();</span>
        <span class="no">C</span><span class="o">[</span><span class="n">i</span><span class="o">][</span><span class="n">j</span><span class="o">]</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span>
      <span class="o">}</span>
    <span class="o">}</span>

    <span class="kt">long</span> <span class="n">start</span> <span class="o">=</span> <span class="nc">System</span><span class="o">.</span><span class="na">nanoTime</span><span class="o">();</span>
    <span class="k">for</span> <span class="o">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">n</span><span class="o">;</span> <span class="n">i</span><span class="o">++)</span> <span class="o">{</span>
      <span class="k">for</span> <span class="o">(</span><span class="kt">int</span> <span class="n">j</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span> <span class="n">j</span> <span class="o">&lt;</span> <span class="n">n</span><span class="o">;</span> <span class="n">j</span><span class="o">++)</span> <span class="o">{</span>
        <span class="k">for</span> <span class="o">(</span><span class="kt">int</span> <span class="n">k</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span> <span class="n">k</span> <span class="o">&lt;</span> <span class="n">n</span><span class="o">;</span> <span class="n">k</span><span class="o">++)</span> <span class="o">{</span>
          <span class="no">C</span><span class="o">[</span><span class="n">i</span><span class="o">][</span><span class="n">j</span><span class="o">]</span> <span class="o">+=</span> <span class="no">A</span><span class="o">[</span><span class="n">i</span><span class="o">][</span><span class="n">k</span><span class="o">]</span> <span class="o">*</span> <span class="no">B</span><span class="o">[</span><span class="n">k</span><span class="o">][</span><span class="n">j</span><span class="o">;]</span>
        <span class="o">}</span>
      <span class="o">}</span>
    <span class="o">}</span>
    <span class="kt">long</span> <span class="n">end</span> <span class="o">=</span> <span class="nc">System</span><span class="o">.</span><span class="na">nanoTime</span><span class="o">();</span>
    <span class="kt">double</span> <span class="n">elapsed</span> <span class="o">=</span> <span class="o">(</span><span class="n">end</span> <span class="o">-</span> <span class="n">start</span><span class="o">)</span> <span class="o">*</span> <span class="mi">1</span><span class="n">e</span><span class="o">-</span><span class="mi">9</span><span class="o">;</span>
    <span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="n">elapsed</span><span class="o">);</span>
  <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>역시 동일한 머신에서 수행하면, 이 자바 코드는 2,738초로 약 46분이 걸린다. 파이썬보다 7.7배는 빠르지만, 여전히 최대 성능 대비 <strong>0.006%</strong> 밖에 안된다.</p>

<h3 id="버전-3-c">버전 3: C</h3>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">#include</span> <span class="cpf">&lt;stdio.h&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;stdlib.h&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;sys/time.h&gt;</span><span class="cp">
</span>
<span class="cp">#define n 4096
</span><span class="kt">double</span> <span class="n">A</span><span class="p">[</span><span class="n">n</span><span class="p">][</span><span class="n">n</span><span class="p">];</span>
<span class="kt">double</span> <span class="n">B</span><span class="p">[</span><span class="n">n</span><span class="p">][</span><span class="n">n</span><span class="p">];</span>
<span class="kt">double</span> <span class="n">C</span><span class="p">[</span><span class="n">n</span><span class="p">][</span><span class="n">n</span><span class="p">];</span>

<span class="kt">float</span> <span class="nf">elapsed</span><span class="p">(</span><span class="k">struct</span> <span class="n">timeval</span> <span class="o">*</span><span class="n">start</span><span class="p">,</span> <span class="k">struct</span> <span class="n">timeval</span> <span class="o">*</span><span class="n">end</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">return</span> <span class="p">(</span><span class="n">end</span><span class="o">-&gt;</span><span class="n">tv_sec</span> <span class="o">-</span> <span class="n">start</span><span class="o">-&gt;</span><span class="n">tv_sec</span><span class="p">)</span> <span class="o">+</span> <span class="mf">1e-6</span> <span class="o">*</span> <span class="p">(</span><span class="n">end</span><span class="o">-&gt;</span><span class="n">tv_usec</span> <span class="o">-</span> <span class="n">start</span><span class="o">-&gt;</span><span class="n">tv_usec</span><span class="p">);</span>
<span class="p">}</span>

<span class="kt">int</span> <span class="nf">main</span><span class="p">(</span><span class="kt">int</span> <span class="n">argc</span><span class="p">,</span> <span class="k">const</span> <span class="kt">char</span><span class="o">*</span> <span class="n">argv</span><span class="p">[])</span> <span class="p">{</span>
  <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">){</span>
    <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">j</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">j</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span> <span class="n">j</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
      <span class="n">A</span><span class="p">[</span><span class="n">i</span><span class="p">][</span><span class="n">j</span><span class="p">]</span> <span class="o">=</span> <span class="p">(</span><span class="kt">double</span><span class="p">)</span> <span class="n">rand</span><span class="p">()</span> <span class="o">/</span> <span class="p">(</span><span class="kt">double</span><span class="p">)</span> <span class="n">RAND_MAX</span><span class="p">;</span>
      <span class="n">B</span><span class="p">[</span><span class="n">i</span><span class="p">][</span><span class="n">j</span><span class="p">]</span> <span class="o">=</span> <span class="p">(</span><span class="kt">double</span><span class="p">)</span> <span class="n">rand</span><span class="p">()</span> <span class="o">/</span> <span class="p">(</span><span class="kt">double</span><span class="p">)</span> <span class="n">RAND_MAX</span><span class="p">;</span>
      <span class="n">C</span><span class="p">[</span><span class="n">i</span><span class="p">][</span><span class="n">j</span><span class="p">]</span> <span class="o">=</span> <span class="mi">0</span><span class="p">.</span><span class="mi">0</span><span class="p">;</span>
    <span class="p">}</span>
  <span class="p">}</span>

  <span class="k">struct</span> <span class="n">timeval</span> <span class="n">start</span><span class="p">,</span> <span class="n">end</span><span class="p">;</span>
  <span class="n">gettimeofday</span><span class="p">(</span><span class="o">&amp;</span><span class="n">start</span><span class="p">,</span> <span class="nb">NULL</span><span class="p">);</span>
  <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">j</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">j</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span> <span class="n">j</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
      <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">k</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">k</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span> <span class="n">k</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">C</span><span class="p">[</span><span class="n">i</span><span class="p">][</span><span class="n">j</span><span class="p">]</span> <span class="o">+=</span> <span class="n">A</span><span class="p">[</span><span class="n">i</span><span class="p">][</span><span class="n">k</span><span class="p">]</span> <span class="o">*</span> <span class="n">B</span><span class="p">[</span><span class="n">k</span><span class="p">][</span><span class="n">j</span><span class="p">];</span>
      <span class="p">}</span>
    <span class="p">}</span>
  <span class="p">}</span>
  <span class="n">gettimeofday</span><span class="p">(</span><span class="o">&amp;</span><span class="n">end</span><span class="p">,</span> <span class="nb">NULL</span><span class="p">);</span>
  <span class="n">printf</span><span class="p">(</span><span class="s">"%0.6f</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">elapsed</span><span class="p">(</span><span class="o">&amp;</span><span class="n">start</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">end</span><span class="p">));</span>
  <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>

</code></pre></div></div>

<p>Clang/LLVM 5.0 컴파일러를 이용하면 대략 1,156초로 약 19분이 걸린다. 대충 자바보다 2배, 파이썬보다 18배 빠르지만 그래도 여전히 최대 성능 대비 <strong>0.014%</strong>다.</p>

<p>여기까지는 프로그래밍 언어 간의 성능 차이를 확인할 수 있었다. 정적 타입 언어이자 머신 코드로 컴파일되어 곧바로 실행되는 C는, 동적 타입 언어이자 인터프리터를 통해 실행되는 파이썬에 비해서 18배나 빠르다. 그럼에도 여전히 우리가 계산한 이상적인 성능에는 발끝도 미치지 못한다. 그러면 이제 뭘 더 해볼 수 있을까?</p>

<h3 id="버전-4-반복문-중첩-순서-바꾸기">버전 4: 반복문 중첩 순서 바꾸기</h3>
<p>이제부터 시도해볼 최적화는 모두 가장 빨랐던 C 프로그램을 기준으로 한다. 먼저 코드의 정확성을 희생하지 않고 반복문이 중첩되는 순서를 바꿔볼 수 있다. i, j, k 총 3개의 반복문이 있으므로 순서를 고려하면 총 6개의 반복문 순서를 얻을 수 있다. 각각을 실행해보면 다음과 같다.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: center">반복 순서 (바깥쪽 -&gt; 안쪽)</th>
      <th style="text-align: right">수행 시간 (초)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center">i -&gt; j -&gt; k</td>
      <td style="text-align: right">1155.77</td>
    </tr>
    <tr>
      <td style="text-align: center">i -&gt; k -&gt; j</td>
      <td style="text-align: right">177.68</td>
    </tr>
    <tr>
      <td style="text-align: center">j -&gt; i -&gt; k</td>
      <td style="text-align: right">1080.61</td>
    </tr>
    <tr>
      <td style="text-align: center">j -&gt; k -&gt; i</td>
      <td style="text-align: right">3056.63</td>
    </tr>
    <tr>
      <td style="text-align: center">k -&gt; i -&gt; j</td>
      <td style="text-align: right">179.21</td>
    </tr>
    <tr>
      <td style="text-align: center">k -&gt; j -&gt; i</td>
      <td style="text-align: right">3032.82</td>
    </tr>
  </tbody>
</table>

<p>순서만 바꿨을 뿐인데 가장 빠른 것과 가장 느린 것의 차이가 17배나 된다!</p>

<p>대체 왜 이런 일이 가능한지를 이해하려면 <a href="../virtual-memory">메모리가 동작하는 방식</a>을 알아야 한다. 단일 칩의 성능 개선이 점차 어려워지자 많은 제조사들은 멀티코어로 눈을 돌렸는데, 이로 인해 성능은 단일 칩의 연산 속도보다는 여러 개의 코어가 얼마나 잘 협동하는지, 특히 그 과정에서 해야 할 작업을 얼마나 효율적으로 공유하고 있는지에 더 큰 영향을 받게 되었다. 즉, 메모리에 어떻게 접근하는지, 특히 여러 번 쓰이는 연속된 메모리 블럭을 최대한 캐시에 잘 붙들여 놓는 것이 중요하다.</p>

<p>우리의 행렬 곱셈 케이스에서, 각 행렬은 메모리에 행 우선 순서 (row-major order) 로 올라가있다. 즉, 다음과 같은 행렬이 있을 때:</p>

\[\begin{bmatrix}
Row 1 (= x_{1,1} x_{1,2} \cdots x_{1,N})\\
Row 2 (= x_{2,1} x_{2,2} \cdots x_{2,N})\\
\cdots \\
Row N (= x_{N,1} x_{N,2} \cdots x_{N,N})\\
\end{bmatrix}\]

<p>이 행렬은 메모리에 다음과 같은 연속된 모양으로 올라간다.</p>

\[\begin{bmatrix}
Row 1 &amp; Row 2 &amp; \cdots &amp; Row N
\end{bmatrix}\]

<p>그러면 우리가 처음에 구현했던 순서인 i -&gt; j -&gt; k (1155.77초)를 다시 살펴보자.</p>
<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">)</span>
  <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">j</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">j</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span> <span class="n">j</span><span class="o">++</span><span class="p">)</span>
    <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">k</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">k</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span> <span class="n">k</span><span class="o">++</span><span class="p">)</span>
      <span class="n">C</span><span class="p">[</span><span class="n">i</span><span class="p">][</span><span class="n">j</span><span class="p">]</span> <span class="o">+=</span> <span class="n">A</span><span class="p">[</span><span class="n">i</span><span class="p">][</span><span class="n">k</span><span class="p">]</span> <span class="o">*</span> <span class="n">B</span><span class="p">[</span><span class="n">k</span><span class="p">][</span><span class="n">j</span><span class="p">];</span>
</code></pre></div></div>
<ul>
  <li>C: 어차피 제일 안쪽 반복문(k) 안에서 접근하는 C의 원소는 한 군데 (i, j) 뿐이다. 공간 지역성이 아주 좋다.</li>
  <li>A: A[i] 행에 대해서 0부터 k까지 순차적으로 접근한다. 공간 지역성이 좋다.</li>
  <li>B: 공간 지역성이 최악이다. B[0] 번째 행의 j번째 원소부터, B[k] 번째 행의 j번쨰 원소까지 접근해야 한다. 그러면 B[0][j]에 접근한 후 B[1][j]에 접근하려면 4096개의 연속된 블럭 메모리를 뛰어넘어가야 한다.</li>
</ul>

<p>제일 빨랐던 i -&gt; k -&gt; j (117.68초) 는 어떨까?</p>
<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">)</span>
  <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">k</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">k</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span> <span class="n">k</span><span class="o">++</span><span class="p">)</span>
    <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">j</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">j</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span> <span class="n">j</span><span class="o">++</span><span class="p">)</span>
      <span class="n">C</span><span class="p">[</span><span class="n">i</span><span class="p">][</span><span class="n">j</span><span class="p">]</span> <span class="o">+=</span> <span class="n">A</span><span class="p">[</span><span class="n">i</span><span class="p">][</span><span class="n">k</span><span class="p">]</span> <span class="o">*</span> <span class="n">B</span><span class="p">[</span><span class="n">k</span><span class="p">][</span><span class="n">j</span><span class="p">];</span>
</code></pre></div></div>
<ul>
  <li>C: 이제 가장 안쪽 반복문은 j다. C[i] 번째 행의 0부터 j번째 원소까지 접근하므로 공간 지역성이 훌륭하다.</li>
  <li>A: 가장 안쪽 반복문 j에서 접근하는 A의 원소는 (i, k) 한 군데 뿐이다. 최고다.</li>
  <li>B: 이제 B의 공간 지역성도 훌륭하다. B[k] 번째 행의 0부터 j번째 원소까지를 순차적으로 접근한다.</li>
</ul>

<p>말이 되는 설명이다. 그러면 실제로는 어떨까? 모든 성능 엔지니어링은 수치를 직접 눈으로 확인하는 프로파일링 과정이 필수적이다. 캐시의 효과를 살펴보기 위해서는 우리는 <code class="language-plaintext highlighter-rouge">valgrind</code> 프로그램에 <code class="language-plaintext highlighter-rouge">--tool=cachegrind</code> 옵션을 줘서, 캐시의 성능을 시뮬레이션해볼 수 있다<sup id="fnref:1"><a href="#fn:1" class="footnote" rel="footnote" role="doc-noteref">1</a></sup> <sup id="fnref:2"><a href="#fn:2" class="footnote" rel="footnote" role="doc-noteref">2</a></sup>. 그러면 앞선 테이블에 추가로 하나의 컬럼을 다음과 같이 추가할 수 있다.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: center">반복 순서 (바깥쪽 -&gt; 안쪽)</th>
      <th style="text-align: right">수행 시간 (초)</th>
      <th style="text-align: right">LLC 미스 율</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center">i -&gt; j -&gt; k</td>
      <td style="text-align: right">1155.77</td>
      <td style="text-align: right">7.7%</td>
    </tr>
    <tr>
      <td style="text-align: center"><strong>i -&gt; k -&gt; j</strong></td>
      <td style="text-align: right">177.68</td>
      <td style="text-align: right"><strong>1.0%</strong></td>
    </tr>
    <tr>
      <td style="text-align: center">j -&gt; i -&gt; k</td>
      <td style="text-align: right">1080.61</td>
      <td style="text-align: right">8.6%</td>
    </tr>
    <tr>
      <td style="text-align: center">j -&gt; k -&gt; i</td>
      <td style="text-align: right">3056.63</td>
      <td style="text-align: right">15.4%</td>
    </tr>
    <tr>
      <td style="text-align: center"><strong>k -&gt; i -&gt; j</strong></td>
      <td style="text-align: right">179.21</td>
      <td style="text-align: right"><strong>1.0%</strong></td>
    </tr>
    <tr>
      <td style="text-align: center">k -&gt; j -&gt; i</td>
      <td style="text-align: right">3032.82</td>
      <td style="text-align: right">15.4%</td>
    </tr>
  </tbody>
</table>

<p>즉, 가장 안쪽 루프에서, 가장 공통된 가장 안쪽 인덱스인 j를 순차적으로 접근하기만 하면, 가장 최적으로 LLC을 활용할 수 있고, 다시 말해 최적의 메모리 접근 지역성을 통해 가장 좋은 성능을 낼 수 있다. 테이블에서 확인할 수 있듯이 가장 안쪽 반복문이 j이기만 하면 i -&gt; k -&gt; j 이든 k -&gt; i -&gt; j 이든 미스 율은 동일하게 1.0% 인 것을 확인할 수 있다. 2초정도의 차이는 오차 범위 이내다.</p>

<p>하지만, 그럼에도 177.68초라는 시간이 걸렸고, 이는 이상적인 최대 성능 대비 <strong>0.093%</strong> 밖에 되지 않는다.</p>

<h3 id="버전-5-컴파일러-최적화">버전 5: 컴파일러 최적화</h3>
<p>C 컴파일러는 프로그램의 원래 의미를 해치지 않는 선에서 여러 가지 단계의 최적화 플래그를 제공한다.</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">-O0</code>: 최적화 안함</li>
  <li><code class="language-plaintext highlighter-rouge">-O1</code>: 적당히 최적화 함</li>
  <li><code class="language-plaintext highlighter-rouge">-O2</code>: 많이 최적화함</li>
  <li><code class="language-plaintext highlighter-rouge">-O3</code>: 공격적으로 최적화함, 컴파일 시간이 오래 걸리고 코드 크기가 늘어남</li>
</ul>

<p>보통 프로덕션에 통용되는 최적화 레벨은 <code class="language-plaintext highlighter-rouge">-O2</code>까지인 것 같다. <code class="language-plaintext highlighter-rouge">-O3</code>는 아주 특수한 케이스가 아니면 잘 쓰지 않는다. 아무튼 j를 마지막에 접근하는 프로그램에 각각의 최적화 플래그를 적용해보면 다음과 같이 또 약간의 성능을 얻는다.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: center">최적화 플래그</th>
      <th style="text-align: right">수행 시간 (초)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center"><code class="language-plaintext highlighter-rouge">-O0</code></td>
      <td style="text-align: right">177.68</td>
    </tr>
    <tr>
      <td style="text-align: center"><code class="language-plaintext highlighter-rouge">-O1</code></td>
      <td style="text-align: right">66.24</td>
    </tr>
    <tr>
      <td style="text-align: center"><code class="language-plaintext highlighter-rouge">-O2</code></td>
      <td style="text-align: right">54.63</td>
    </tr>
    <tr>
      <td style="text-align: center"><code class="language-plaintext highlighter-rouge">-O3</code></td>
      <td style="text-align: right">55.58</td>
    </tr>
  </tbody>
</table>

<p>하지만, 그럼에도 54.63초가 걸렸고, 이는 이상적인 최대 성능 대비 <strong>0.301%</strong> 밖에 안된다.</p>

<h3 id="버전-6-병렬-처리">버전 6: 병렬 처리</h3>
<p>대체 뭐가 성능을 가로막고 있는 걸까?</p>

<p>앞에서 살펴본 우리의 하드웨어는 분명 18개의 물리 코어를 갖고 있다고 했지만, 우리는 지금까지 단 하나의 코어만 사용하고 있었다. 나머지 17개의 물리 코어는 놀고 있었다! 얘네들을 쉼없이 일하게 시켜야 한다.</p>

<p>병렬 처리를 위한 다양한 라이브러리가 있지만, 여기서는 MIT에서 만든 <a href="https://cilk.mit.edu/programming/#:~:text=Programming%20in,language%20extension.">Cilk</a>를 쓴다 (당연하다; MIT 교육이니). Cilk는 C/C++ 언어를 확장하여 병렬로 처리할 작업을 논리적으로 쉽게 표현할 수 있도록 해준다. 그 중에서도 <code class="language-plaintext highlighter-rouge">cilk_for</code>를 이용하면 <code class="language-plaintext highlighter-rouge">for</code>와 유사한 문법 구조를 따르면서도 반복문을 직접 물리 코어에다가 병렬로 수행하게 할 수 있다.</p>

<p>앞에서 반복문의 접근 순서에 따라 속도가 달라졌듯이, 어떤 반복문을 병렬로 수행할 것인지도 성능에 영향을 미친다. 가장 빨랐던 i -&gt; k -&gt; j 를 기준으로 생각해보자. 일단 k는 병렬화 할 수 없다. 왜냐하면 같은 i, j 조합에 대해서 계속 누적되기 때문이다.</p>
<ul>
  <li>k=0 일 때: <code class="language-plaintext highlighter-rouge">C[i][j] += A[i][0] * B[0][j]</code></li>
  <li>k=1 일 때: <code class="language-plaintext highlighter-rouge">C[i][j] += A[i][1] * B[1][j]</code></li>
  <li>k=2 일 때: <code class="language-plaintext highlighter-rouge">C[i][j] += A[i][2] * B[2][j]</code></li>
</ul>

<p>즉, 이렇게 순차적인 의존성이 있을 때는 병렬로 수행할 수 없다.</p>

<p>그러면 i와 j에 대해서면 병렬 반복인 <code class="language-plaintext highlighter-rouge">cilk_for</code>를 적용해볼 수 있다. 어떤 걸 적용해야할까? 모든 3개의 조합을 시도해보면 다음과 같은 결과를 얻는다.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: center"><code class="language-plaintext highlighter-rouge">cilk_for</code> 적용 루프</th>
      <th style="text-align: right">수행 시간 (초)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center"><strong>i</strong></td>
      <td style="text-align: right"><strong>3.18</strong></td>
    </tr>
    <tr>
      <td style="text-align: center">j</td>
      <td style="text-align: right">531.71</td>
    </tr>
    <tr>
      <td style="text-align: center">i, j</td>
      <td style="text-align: right">10.64</td>
    </tr>
  </tbody>
</table>

<p>즉, 안쪽 루프보다는 바깥쪽 루프를 병렬 처리하는 것이 좋다. 왜냐하면 가장 큰 병렬성(n개의 독립적 작업)을 얻기 때문이다. 그리고 앞에서 고려한 캐시 지역성(cache locality)을 더 잘 활용한다. 헷갈리면 그냥 가장 바깥쪽 루프만 병렬처리 하면 된다.</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">cilk_for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">)</span>
  <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">k</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">k</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span> <span class="n">k</span><span class="o">++</span><span class="p">)</span>
    <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">j</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">j</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span> <span class="n">j</span><span class="o">++</span><span class="p">)</span>
      <span class="n">C</span><span class="p">[</span><span class="n">i</span><span class="p">][</span><span class="n">j</span><span class="p">]</span> <span class="o">+=</span> <span class="n">A</span><span class="p">[</span><span class="n">i</span><span class="p">][</span><span class="n">k</span><span class="p">]</span> <span class="o">*</span> <span class="n">B</span><span class="p">[</span><span class="n">k</span><span class="p">][</span><span class="n">j</span><span class="p">];</span>
</code></pre></div></div>

<p>최종 코드는 위와 같다. 이렇게 3.18초, 최대 성능 대비 <strong>5.17%</strong>를 끌어내었다. 드디어 한 자릿 수까지 왔다. 참고로 이 수치는 18개의 물리 코어를 모두 활용해서 얻은 수치이며, 병렬 처리를 하지 않은 버전 대비 약 18배 빠르다.</p>

<h3 id="버전-7-메모리-접근-최적화">버전 7: 메모리 접근 최적화</h3>
<p>이제 우리 프로그램은 타입을 컴파일 타임에 알아내어서 머신 코드로 바로 실행되는 바이너리로 컴파일되고, 공간 지역성도 잘 지켰고, 컴파일러 최적화도 했고, 모든 18개의 물리 코어를 다 쓰도록 했다. 사실 여기까지가 내가 그동안 알고 있던 최적화의 내용이기도 하다. 그런데도 왜 여전히 최대 성능의 5% 밖에 안되는 걸까? 어떻게 하면 하드웨어의 고급 기능을 다 활용할 수 있을까?</p>

<p>사실 우리는 아직까지 하드웨어 캐시를 다 활용하고 있지 못하다. 데이터를 최대한 <strong>재사용</strong>하도록 계산의 구조를 재정렬하면 캐시 미스를 최소한으로 줄이고 캐시 히트를 최대한 늘릴 수 있다.</p>

<p>성능 엔지니어링에서 중요한 것은 프로파일링, 수치를 계산해보고 눈으로 확인하는 일이다. 먼저, 행렬 C의 행 하나를 전부 계산하기 위해서 반복문 코드가 메모리 접근을 몇 번 해야하는지를 계산해보자.</p>
<ul>
  <li>4096 \(\times\) 1 = 4096 번 C에 쓰기 작업이 필요하고,</li>
  <li>4096 \(\times\) 1 = 4096 번 A에서 읽기 잡업이 필요하고,</li>
  <li>4096 \(\times\) 4096 = 16,777,216 번 B에서 읽기 작업이 필요하다.</li>
</ul>

<p><img src="/assets/img/matrix-mul-row.png" alt="row" /></p>

<p>따라서 총 16,785,408 번의 메모리 접근이 발생한다.</p>

<p>데이터를 재사용하려면 메모리에 어떤 식으로 접근해야 할까? 행렬 C를 계산할 때 행 하나가 아니라, 64x64 크기의 <em>타일</em>로 접근한다고 해보자.</p>
<ul>
  <li>64 \(\times\) 64 (타일 크기) = 4096 번 C에 쓰기 작업이 필요하고,</li>
  <li>64 \(\times\) 4096 = 262,144 번 A에서 읽어야 하고,</li>
  <li>4096 \(\times\) 64 = 262,144 번 B에서 읽어야 한다.</li>
</ul>

<p><img src="/assets/img/matrix-mul-block.png" alt="block" /></p>

<p>따라서 총 528,384 번의 메모리 접근이 필요하다.</p>

<p>오호. 분명 같은 계산을 하는데, 어떤 부분을 먼저 하느냐에 따라서 메모리 접근 횟수가 줄었다. 알고리즘 시간에 배웠던 분할 정복의 위력을 눈으로 확인한 순간이다. 역시 이 타일의 크기도 바꿔볼 수 있으니, 이걸 조절할 수 있도록 정사각형 타일의 크기를 s라고 했을 때, 나이브하게는 다음과 같이 구현할 수 있다.</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">ih</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">ih</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span> <span class="n">ih</span> <span class="o">+=</span> <span class="n">s</span><span class="p">)</span>
  <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">jh</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">jh</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span> <span class="n">jh</span> <span class="o">+=</span> <span class="n">s</span><span class="p">)</span>
    <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">kh</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">kh</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span> <span class="n">kh</span> <span class="o">+=</span> <span class="n">s</span><span class="p">)</span>
      <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">il</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">il</span> <span class="o">&lt;</span> <span class="n">s</span><span class="p">;</span> <span class="n">il</span><span class="o">++</span><span class="p">)</span>
        <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">kl</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">kl</span> <span class="o">&lt;</span> <span class="n">s</span><span class="p">;</span> <span class="n">kl</span><span class="o">++</span><span class="p">)</span>
          <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">jl</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">jl</span> <span class="o">&lt;</span> <span class="n">s</span><span class="p">;</span> <span class="n">jl</span><span class="o">++</span><span class="p">)</span>
            <span class="n">C</span><span class="p">[</span><span class="n">ih</span> <span class="o">+</span> <span class="n">il</span><span class="p">][</span><span class="n">jh</span> <span class="o">+</span> <span class="n">jl</span><span class="p">]</span> <span class="o">+=</span> <span class="n">A</span><span class="p">[</span><span class="n">ih</span> <span class="o">+</span> <span class="n">il</span><span class="p">][</span><span class="n">kh</span> <span class="o">+</span> <span class="n">kl</span><span class="p">]</span> <span class="o">*</span> <span class="n">B</span><span class="p">[</span><span class="n">kh</span> <span class="o">+</span> <span class="n">kl</span><span class="p">][</span><span class="n">jh</span> <span class="o">+</span> <span class="n">jl</span><span class="p">];</span>
</code></pre></div></div>

<p>이 코드는 몇 가지 생각할 부분이 있다.</p>
<ul>
  <li>인덱스를 두 단계, high와 low로 나누어서, 예를 들어 원래 인덱스인 i를 ih에 il를 더해서 접근하게 만들고, high의 크기를 우리가 원하는 타일의 크기인 s로 설정한다. 이렇게 하면 가장 바깥쪽 루프는 타일 크기만큼 뛰어넘으면서, 안쪽 루프는 그 타일 안에서 low 인덱스를 1씩 증가하여 접근할 수 있다.</li>
  <li>안쪽 반복문의 접근 순서는 우리가 찾아내었던 가장 빠른 순서인 i -&gt; k -&gt; j 그대로인데, 바깥쪽 반복문은 i -&gt; j -&gt; k이다.</li>
  <li>이전과 마찬가지로 k는 병렬화가 불가능하다. 가장 최적은 바깥쪽 i, j 두개만 병렬화 하는 것.</li>
</ul>

<p>그래서 위 코드를 Cilk로 병렬화 하면 다음과 같다.</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">cilk_for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">ih</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">ih</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span> <span class="n">ih</span> <span class="o">+=</span> <span class="n">s</span><span class="p">)</span>
  <span class="n">cilk_for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">jh</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">jh</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span> <span class="n">jh</span> <span class="o">+=</span> <span class="n">s</span><span class="p">)</span>
    <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">kh</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">kh</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span> <span class="n">kh</span> <span class="o">+=</span> <span class="n">s</span><span class="p">)</span>
      <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">il</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">il</span> <span class="o">&lt;</span> <span class="n">s</span><span class="p">;</span> <span class="n">il</span><span class="o">++</span><span class="p">)</span>
        <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">kl</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">kl</span> <span class="o">&lt;</span> <span class="n">s</span><span class="p">;</span> <span class="n">kl</span><span class="o">++</span><span class="p">)</span>
          <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">jl</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">jl</span> <span class="o">&lt;</span> <span class="n">s</span><span class="p">;</span> <span class="n">jl</span><span class="o">++</span><span class="p">)</span>
            <span class="n">C</span><span class="p">[</span><span class="n">ih</span> <span class="o">+</span> <span class="n">il</span><span class="p">][</span><span class="n">jh</span> <span class="o">+</span> <span class="n">jl</span><span class="p">]</span> <span class="o">+=</span> <span class="n">A</span><span class="p">[</span><span class="n">ih</span> <span class="o">+</span> <span class="n">il</span><span class="p">][</span><span class="n">kh</span> <span class="o">+</span> <span class="n">kl</span><span class="p">]</span> <span class="o">*</span> <span class="n">B</span><span class="p">[</span><span class="n">kh</span> <span class="o">+</span> <span class="n">kl</span><span class="p">][</span><span class="n">jh</span> <span class="o">+</span> <span class="n">jl</span><span class="p">];</span>
</code></pre></div></div>

<p>이전과 비슷하게 이번에도 타일 크기 s를 조절해가면서 실험을 해볼 수 있다.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: right">타일 크기</th>
      <th style="text-align: right">수행 시간 (초)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: right">4</td>
      <td style="text-align: right">6.74</td>
    </tr>
    <tr>
      <td style="text-align: right">8</td>
      <td style="text-align: right">2.76</td>
    </tr>
    <tr>
      <td style="text-align: right">16</td>
      <td style="text-align: right">2.49</td>
    </tr>
    <tr>
      <td style="text-align: right"><strong>32</strong></td>
      <td style="text-align: right"><strong>1.74</strong></td>
    </tr>
    <tr>
      <td style="text-align: right">64</td>
      <td style="text-align: right">2.33</td>
    </tr>
    <tr>
      <td style="text-align: right">128</td>
      <td style="text-align: right">2.13</td>
    </tr>
  </tbody>
</table>

<p>타일 크기가 32일 때 수행 시간도 절반 가까이로 줄어든 것을 볼 수 있다. 참고로 이 32라는 타일 크기는 항상 통용되는 값이 아니라, 문제에 따라 조절해야 하는 파라미터이다. 최적의 성능을 위한 값은 이렇게 실험을 통해 알아내는 수 밖에 없다. 아무튼 이렇게 최대 성능의 <strong>9.45%</strong>를 끌어내었다.</p>

<p>참고로 타일링은 같은 개수의 원소를 계산하는데 필요한 메모리 접근 횟수만 줄이는게 아니라, 캐시 히트율도 엄청나게 올린다. 버전 6에서 그냥 Cilk를 적용한 것과 이번 버전에서 타일링을 적용한 것 각각의 캐시 성능을 비교하면, LLC 기준으로 95%의 성능 향상을 볼 수 있다.</p>

<table>
  <thead>
    <tr>
      <th>버전</th>
      <th style="text-align: right">캐시 레퍼런스 (백만)</th>
      <th style="text-align: right">L1 데이터 캐시 미스 (백만)</th>
      <th style="text-align: right">LLC 미스 (백만)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>버전 6 (병렬 처리 )</td>
      <td style="text-align: right">104,090</td>
      <td style="text-align: right">17,220</td>
      <td style="text-align: right">8,600</td>
    </tr>
    <tr>
      <td>버전 7 (타일링)</td>
      <td style="text-align: right">64,690</td>
      <td style="text-align: right">11,777</td>
      <td style="text-align: right">416</td>
    </tr>
  </tbody>
</table>

<h3 id="버전-8-병렬로-분할정복">버전 8: 병렬로 분할정복</h3>
<p>현대 멀티 코어 프로세서의 캐시 및 메모리 구조를 조금만 더 자세히 살펴보자.</p>

<table>
  <thead>
    <tr>
      <th>단계</th>
      <th>크기</th>
      <th>정보</th>
      <th style="text-align: right">레이턴시 (ns)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>메인 메모리</td>
      <td>60GB</td>
      <td> </td>
      <td style="text-align: right">50</td>
    </tr>
    <tr>
      <td>LLC (L3)</td>
      <td>25MB</td>
      <td>20-way, shared</td>
      <td style="text-align: right">12</td>
    </tr>
    <tr>
      <td>L2</td>
      <td>256KB</td>
      <td>8-way, private</td>
      <td style="text-align: right">4</td>
    </tr>
    <tr>
      <td>L1-d</td>
      <td>32KB</td>
      <td>8-way, private</td>
      <td style="text-align: right">2</td>
    </tr>
    <tr>
      <td>L1-i</td>
      <td>32KB</td>
      <td>8-way, private</td>
      <td style="text-align: right">2</td>
    </tr>
  </tbody>
</table>

<p>코어들 간에 공유되는 LLC를 제외하면, 코어 독접적 캐시는 L1, L2 총 두 개가 있고 이 둘은 다른 캐시에 비해서 매우매우 빠르다 (order of magnitude). 따라서 이 두 캐시를 최대한 활용하는 것이 성능 엔지니어링에 있어 필수적이다.</p>

<p>어떻게 하면 좋을까? 핵심 아이디어는 알고리즘 시간에 배웠던 분할 정복을 떠올리는 것이다. 타일링을 항상 같은 크기인 s로 하는 것이 아니라, 2의 모든 거듭 제곱 크기에 대해서 <strong>동시에</strong> 타일링을 진행한다. 예를 들어 NxN 행렬의 곱셈 문제를 다음과 같이 N/2xN/2 행렬을 8번 곱한 다음 NxN 행렬을 한번 더하는 문제로 바꾸면, 부분 문제인 N/2xN/2의 곱셈이 결국 타일링과 같아진다. 그리고 이걸 재귀적으로 적용해서 타일링된 행렬의 곱셈 역시 타일링하여 계산하게 되면, 메모리 접근 성능과 캐시 활용을 극적으로 끌어올릴 수 있다. 추가로 이렇게 N/2xN/2로 쪼갠 부분 문제들은 서로 의존성이 없기 때문에, 이 부분 문제들을 풀 때 병렬 처리를 적극 적용할 수 있게 된다.</p>

\[\begin{align*}
\begin{bmatrix}
C_{0,0} &amp; C_{0,1} \\
C_{1,0} &amp; C_{1,1} \\
\end{bmatrix}
&amp;=
\begin{bmatrix}
A_{0,0} &amp; A_{0,1} \\
A_{1,0} &amp; A_{1,1} \\
\end{bmatrix}
\times
\begin{bmatrix}
B_{0,0} &amp; B_{0,1} \\
B_{1,0} &amp; B_{1,1} \\
\end{bmatrix}
\\

&amp;=
\begin{bmatrix}
A_{0,0}B_{0,0} &amp; A_{0,0}B_{0,1} \\
A_{1,0}B_{0,0} &amp; A_{1,0}B_{0,1} \\
\end{bmatrix}
+
\begin{bmatrix}
A_{0,1}B_{1,0} &amp; A_{0,1}B_{1,1} \\
A_{1,1}B_{1,0} &amp; A_{1,1}B_{1,1} \\
\end{bmatrix}
\\
\end{align*}\]

<p>이렇게 문제를 쪼개면 결국 가장 풀기 쉬운 부분 문제인 기저 조건 (Base Case) 까지 닿게 된다. 그런데 기저 조건의 크기가 너무 작으면, 즉 문제를 너무 잘게 쪼개면 오히려 성능이 떨어질 수도 있다. 그러므로 이 접근에서 튜닝할 수 있는 파라미터는 <strong>기저 조건의 크기</strong>가 된다. 따라서, 구현은 다음과 같이 된다.</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">void</span> <span class="nf">mm_base</span><span class="p">(</span>
  <span class="kt">double</span> <span class="o">*</span><span class="kr">restrict</span> <span class="n">C</span><span class="p">,</span> <span class="kt">int</span> <span class="n">n_C</span><span class="p">,</span>
  <span class="kt">double</span> <span class="o">*</span><span class="kr">restrict</span> <span class="n">A</span><span class="p">,</span> <span class="kt">int</span> <span class="n">n_A</span><span class="p">,</span>
  <span class="kt">double</span> <span class="o">*</span><span class="kr">restrict</span> <span class="n">B</span><span class="p">,</span> <span class="kt">int</span> <span class="n">n_B</span><span class="p">,</span>
  <span class="kt">int</span> <span class="n">n</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">)</span>
    <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">k</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">k</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span> <span class="n">k</span><span class="o">++</span><span class="p">)</span>
      <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">j</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">j</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span> <span class="n">j</span><span class="o">++</span><span class="p">)</span>
        <span class="n">C</span><span class="p">[</span><span class="n">n_C</span> <span class="o">*</span> <span class="n">i</span> <span class="o">+</span> <span class="n">j</span><span class="p">]</span> <span class="o">+=</span> <span class="n">A</span><span class="p">[</span><span class="n">n_A</span> <span class="o">*</span> <span class="n">i</span> <span class="o">+</span> <span class="n">k</span><span class="p">]</span> <span class="o">*</span> <span class="n">B</span><span class="p">[</span><span class="n">n_B</span> <span class="o">*</span> <span class="n">k</span> <span class="o">+</span> <span class="n">j</span><span class="p">];</span>
<span class="p">}</span>

<span class="kt">void</span> <span class="nf">mm_dac</span><span class="p">(</span>
  <span class="kt">double</span> <span class="o">*</span><span class="kr">restrict</span> <span class="n">C</span><span class="p">,</span> <span class="kt">int</span> <span class="n">n_C</span><span class="p">,</span>
  <span class="kt">double</span> <span class="o">*</span><span class="kr">restrict</span> <span class="n">A</span><span class="p">,</span> <span class="kt">int</span> <span class="n">n_A</span><span class="p">,</span>
  <span class="kt">double</span> <span class="o">*</span><span class="kr">restrict</span> <span class="n">B</span><span class="p">,</span> <span class="kt">int</span> <span class="n">n_B</span><span class="p">,</span>
  <span class="kt">int</span> <span class="n">n</span><span class="p">)</span> <span class="p">{</span>
  <span class="c1">// C += A * B</span>
  <span class="k">if</span> <span class="p">(</span><span class="n">n</span> <span class="o">&lt;=</span> <span class="n">THRESHOLD</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">mm_base</span> <span class="p">(</span><span class="n">C</span><span class="p">,</span> <span class="n">n_C</span><span class="p">,</span> <span class="n">A</span><span class="p">,</span> <span class="n">n_A</span><span class="p">,</span> <span class="n">B</span><span class="p">,</span> <span class="n">n_B</span><span class="p">,</span> <span class="n">n</span><span class="p">);</span>
  <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
<span class="cp">#define X(M,r,c) (M + (r*(n_ ## M) + c)*(n/2))
</span>    <span class="n">cilk_spawn</span> <span class="n">mm_dac</span><span class="p">(</span><span class="n">X</span><span class="p">(</span><span class="n">C</span><span class="p">,</span><span class="mi">0</span><span class="p">,</span><span class="mi">0</span><span class="p">),</span> <span class="n">n_C</span><span class="p">,</span> <span class="n">X</span><span class="p">(</span><span class="n">A</span><span class="p">,</span><span class="mi">0</span><span class="p">,</span><span class="mi">0</span><span class="p">),</span> <span class="n">n_A</span><span class="p">,</span> <span class="n">X</span><span class="p">(</span><span class="n">B</span><span class="p">,</span><span class="mi">0</span><span class="p">,</span><span class="mi">0</span><span class="p">),</span> <span class="n">n_B</span><span class="p">,</span> <span class="n">n</span><span class="o">/</span><span class="mi">2</span><span class="p">);</span>
    <span class="n">cilk_spawn</span> <span class="n">mm_dac</span><span class="p">(</span><span class="n">X</span><span class="p">(</span><span class="n">C</span><span class="p">,</span><span class="mi">0</span><span class="p">,</span><span class="mi">1</span><span class="p">),</span> <span class="n">n_C</span><span class="p">,</span> <span class="n">X</span><span class="p">(</span><span class="n">A</span><span class="p">,</span><span class="mi">0</span><span class="p">,</span><span class="mi">0</span><span class="p">),</span> <span class="n">n_A</span><span class="p">,</span> <span class="n">X</span><span class="p">(</span><span class="n">B</span><span class="p">,</span><span class="mi">0</span><span class="p">,</span><span class="mi">1</span><span class="p">),</span> <span class="n">n_B</span><span class="p">,</span> <span class="n">n</span><span class="o">/</span><span class="mi">2</span><span class="p">);</span>
    <span class="n">cilk_spawn</span> <span class="n">mm_dac</span><span class="p">(</span><span class="n">X</span><span class="p">(</span><span class="n">C</span><span class="p">,</span><span class="mi">1</span><span class="p">,</span><span class="mi">0</span><span class="p">),</span> <span class="n">n_C</span><span class="p">,</span> <span class="n">X</span><span class="p">(</span><span class="n">A</span><span class="p">,</span><span class="mi">1</span><span class="p">,</span><span class="mi">0</span><span class="p">),</span> <span class="n">n_A</span><span class="p">,</span> <span class="n">X</span><span class="p">(</span><span class="n">B</span><span class="p">,</span><span class="mi">0</span><span class="p">,</span><span class="mi">0</span><span class="p">),</span> <span class="n">n_B</span><span class="p">,</span> <span class="n">n</span><span class="o">/</span><span class="mi">2</span><span class="p">);</span>
               <span class="n">mm_dac</span><span class="p">(</span><span class="n">X</span><span class="p">(</span><span class="n">C</span><span class="p">,</span><span class="mi">1</span><span class="p">,</span><span class="mi">1</span><span class="p">),</span> <span class="n">n_C</span><span class="p">,</span> <span class="n">X</span><span class="p">(</span><span class="n">A</span><span class="p">,</span><span class="mi">1</span><span class="p">,</span><span class="mi">0</span><span class="p">),</span> <span class="n">n_A</span><span class="p">,</span> <span class="n">X</span><span class="p">(</span><span class="n">B</span><span class="p">,</span><span class="mi">0</span><span class="p">,</span><span class="mi">1</span><span class="p">),</span> <span class="n">n_B</span><span class="p">,</span> <span class="n">n</span><span class="o">/</span><span class="mi">2</span><span class="p">);</span>
    <span class="n">cilk_sync</span><span class="p">;</span>
    <span class="n">cilk_spawn</span> <span class="n">mm_dac</span><span class="p">(</span><span class="n">X</span><span class="p">(</span><span class="n">C</span><span class="p">,</span><span class="mi">0</span><span class="p">,</span><span class="mi">0</span><span class="p">),</span> <span class="n">n_C</span><span class="p">,</span> <span class="n">X</span><span class="p">(</span><span class="n">A</span><span class="p">,</span><span class="mi">0</span><span class="p">,</span><span class="mi">1</span><span class="p">),</span> <span class="n">n_A</span><span class="p">,</span> <span class="n">X</span><span class="p">(</span><span class="n">B</span><span class="p">,</span><span class="mi">1</span><span class="p">,</span><span class="mi">0</span><span class="p">),</span> <span class="n">n_B</span><span class="p">,</span> <span class="n">n</span><span class="o">/</span><span class="mi">2</span><span class="p">);</span>
    <span class="n">cilk_spawn</span> <span class="n">mm_dac</span><span class="p">(</span><span class="n">X</span><span class="p">(</span><span class="n">C</span><span class="p">,</span><span class="mi">0</span><span class="p">,</span><span class="mi">1</span><span class="p">),</span> <span class="n">n_C</span><span class="p">,</span> <span class="n">X</span><span class="p">(</span><span class="n">A</span><span class="p">,</span><span class="mi">0</span><span class="p">,</span><span class="mi">1</span><span class="p">),</span> <span class="n">n_A</span><span class="p">,</span> <span class="n">X</span><span class="p">(</span><span class="n">B</span><span class="p">,</span><span class="mi">1</span><span class="p">,</span><span class="mi">1</span><span class="p">),</span> <span class="n">n_B</span><span class="p">,</span> <span class="n">n</span><span class="o">/</span><span class="mi">2</span><span class="p">);</span>
    <span class="n">cilk_spawn</span> <span class="n">mm_dac</span><span class="p">(</span><span class="n">X</span><span class="p">(</span><span class="n">C</span><span class="p">,</span><span class="mi">1</span><span class="p">,</span><span class="mi">0</span><span class="p">),</span> <span class="n">n_C</span><span class="p">,</span> <span class="n">X</span><span class="p">(</span><span class="n">A</span><span class="p">,</span><span class="mi">1</span><span class="p">,</span><span class="mi">1</span><span class="p">),</span> <span class="n">n_A</span><span class="p">,</span> <span class="n">X</span><span class="p">(</span><span class="n">B</span><span class="p">,</span><span class="mi">1</span><span class="p">,</span><span class="mi">0</span><span class="p">),</span> <span class="n">n_B</span><span class="p">,</span> <span class="n">n</span><span class="o">/</span><span class="mi">2</span><span class="p">);</span>
               <span class="n">mm_dac</span><span class="p">(</span><span class="n">X</span><span class="p">(</span><span class="n">C</span><span class="p">,</span><span class="mi">1</span><span class="p">,</span><span class="mi">1</span><span class="p">),</span> <span class="n">n_C</span><span class="p">,</span> <span class="n">X</span><span class="p">(</span><span class="n">A</span><span class="p">,</span><span class="mi">1</span><span class="p">,</span><span class="mi">1</span><span class="p">),</span> <span class="n">n_A</span><span class="p">,</span> <span class="n">X</span><span class="p">(</span><span class="n">B</span><span class="p">,</span><span class="mi">1</span><span class="p">,</span><span class="mi">1</span><span class="p">),</span> <span class="n">n_B</span><span class="p">,</span> <span class="n">n</span><span class="o">/</span><span class="mi">2</span><span class="p">);</span>
    <span class="n">cilk_sync</span><span class="p">;</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">restrict</code> 키워드는 포인터가 메모리의 <strong>겹치지 않는 영역</strong>만을 가리킨다는 것을 컴파일러에게 알려준다. 즉, <strong>포인터 알리아싱</strong>이 없다는 것을 컴파일러에게 힌트로 보장해주어서 추가적인 최적화가 가능하게 한다.</li>
  <li>기저 사례를 처리하는 <code class="language-plaintext highlighter-rouge">mm_base</code> 함수는 특정 임계 크기 이하일 때 직접 곱셈을 수행하는 역할을 한다. 이 값이 메모리 연산에 적절한 크기라면 성능이 극적으로 개선된다.</li>
  <li><code class="language-plaintext highlighter-rouge">cilk_spawn</code>은 뒤에 호출하는 함수를 병렬로 처리하면서 이후 작업을 계속 하도록 한다. 그리고 이렇게 펼쳐진 작업들은 <code class="language-plaintext highlighter-rouge">cilk_sync</code>에서 만나게 된다. 즉, 첫 네 개의 연산은 각각 병렬로 작업을 세 개 시작하고 마지막은 현재 프로세스가 직접 처리한다. 그 후 첫번째 <code class="language-plaintext highlighter-rouge">cilk_sync</code>에서 모든 작업이 만나 동기화된다. 이후 뒤 네 개의 작업도 마찬가지로 동작한다. 이를 통해서 <code class="language-plaintext highlighter-rouge">C += A * B</code> 연산을 위의 수식에 따라 8개의 곱셈과 하나의 덧셈으로 처리할 수 있다.</li>
</ul>

<p>그러면 기저 조건의 임계 크기가 얼마가 되어야 최적의 성능을 얻을 수 있을까? 이 역시 실험으로 찾아내야 하는 파라미터 값이다. 당연하지만 여기서 작업을 이전의 절반으로 나누고 있으므로 이 기저 조건 값은 2의 배수여야 잘 동작 한다.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: right">기저 조건 크기 (Threshold)</th>
      <th style="text-align: right">실행 시간 (초)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: right">4</td>
      <td style="text-align: right">3.00</td>
    </tr>
    <tr>
      <td style="text-align: right">8</td>
      <td style="text-align: right">1.34</td>
    </tr>
    <tr>
      <td style="text-align: right">16</td>
      <td style="text-align: right">1.34</td>
    </tr>
    <tr>
      <td style="text-align: right"><strong>32</strong></td>
      <td style="text-align: right"><strong>1.30</strong></td>
    </tr>
    <tr>
      <td style="text-align: right">64</td>
      <td style="text-align: right">1.95</td>
    </tr>
    <tr>
      <td style="text-align: right">128</td>
      <td style="text-align: right">2.08</td>
    </tr>
  </tbody>
</table>

<p>32가 최적의 값이다. 이게 왜 최적의 값일까? 기저 조건이 32라는 뜻은, 문제를 쪼개어서 32x32 크기의 타일이 한번에 풀어야 하는 최소 행렬의 크기라는 뜻이다. 위의 코드에서 \(C += A \times B\) 를 계산하므로 A, B, C 총 세 개의 행렬(타일)이 <strong>모두</strong> 캐시에 올라가면 아주 빠를 것이다. 이걸 바탕으로 계산해보면 32 $\times$ 32 $\times$ 8 (double의 크기) $\times$ 3 = 24KB로, L1 캐시에 딱 넘치지 않고 올라간다는 것을 알 수 있다. 만약 크기가 64였다면 96KB로 L2 캐시엔 들어가지만 L1은 넘친다. L1, L2 모두 넘치지 않으면서 가장 큰 크기의 타일링을 처리할 때 가장 빠른 실행 시간을 보인 것이다. 그리고 L1, L2 캐시는 각각 코어 전용으로 달려있기 때문에, <code class="language-plaintext highlighter-rouge">cilk_spawn</code>을 통해서 재귀적으로 분할된 문제들을 풀 때 모든 18개의 코어가 각자에 독점적으로 달려있는 L1, L2 캐시에 기저 조건으로 풀어야 하는 문제의 모든 행렬 데이터가 담겨 있기 때문에 가장 빠른 성능을 낸 것이다.</p>

<p>아무튼 또 한번의 극적인 최적화가 이루어 져서 기존 대비 36%의 성능을 개선하여 최대 성능의 12.65%를 달성했다. 근데 정말로 계산한 대로 캐시를 더 활용한 걸까? 이 역시 캐시 프로파일링을 살펴보면 알 수 있다.</p>

<table>
  <thead>
    <tr>
      <th>버전</th>
      <th style="text-align: right">캐시 레퍼런스 (백만)</th>
      <th style="text-align: right">L1 데이터 캐시 미스 (백만)</th>
      <th style="text-align: right">LLC 미스 (백만)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>버전 6 (병렬 처리 )</td>
      <td style="text-align: right">104,090</td>
      <td style="text-align: right">17,220</td>
      <td style="text-align: right">8,600</td>
    </tr>
    <tr>
      <td>버전 7 (타일링)</td>
      <td style="text-align: right">64,690</td>
      <td style="text-align: right">11,777</td>
      <td style="text-align: right">416</td>
    </tr>
    <tr>
      <td>버전 8 (병렬로 분할 정복)</td>
      <td style="text-align: right">58,230</td>
      <td style="text-align: right">9,407</td>
      <td style="text-align: right">64</td>
    </tr>
  </tbody>
</table>

<p>LLC 기준 캐시 미스율이 약 85% 개선되었다.</p>

<h3 id="버전-9-vectorization">버전 9: Vectorization</h3>
<p>아직 끝난게 아니다.</p>

<p>우리의 실험 머신 스펙을 다시 살펴보자. 처음에 이론적인 Peak GFLOPS를 잴 때 여러가지를 고려했는데 그 중 하나가 바로 AVX, 즉 하드웨어 자체에서 제공하는 벡터화 (Vectorization) 를 활용하는 것이었다. 그래서 우리는 하나의 FPU가 최대 8배의 연산을 처리할 수 있다고 가정할 수 있었다. 지금까지 우리의 최적화는 대부분 캐시나 메모리와 관련된 것들이 많았기 때문에, 이제는 특정 하드웨어에서만 가능한 최적화에 기대볼 수 있다.</p>

<p>현대 프로세서에 탑재된 벡터 하드웨어는 데이터를 SIMD (Single Instruction Stream, Multiple Data Stream) 방식으로 처리할 수 있다. 256 비트 크기의 레인인 벡터 레지스터라는 것이 따로 탑재되어 있어서, 같은 연산을 서로 다른 데이터에다가 적용할 때 이 크기만큼 한번에 처리할 수 있게 해준다. 실제로는 코드를 직접 수정하지 않아도 우리보다 똑똑한 컴파일러가 <code class="language-plaintext highlighter-rouge">-O2</code> 레벨 이상의 최적화 옵션에서 이걸 알아서 해주기도 한다. 컴파일러가 어느 부분을 벡터화했는지 알고 싶으면 <code class="language-plaintext highlighter-rouge">-Rpass=vector</code> 옵션을 줘서 벡터화 관련 리포트를 받아볼 수 있다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>clang <span class="nt">-O3</span> <span class="nt">-std</span><span class="o">=</span>c99 mm.c <span class="nt">-o</span> mm <span class="nt">-Rpass</span><span class="o">=</span>vector
mm.c:42:7: remark: vectorized loop <span class="o">(</span>vectorization width: 2, interleaved count: 2<span class="o">)</span> <span class="o">[</span><span class="nt">-Rpass</span><span class="o">=</span>loop-vectorize]
         <span class="k">for</span> <span class="o">(</span>int j <span class="o">=</span> 0<span class="p">;</span> j &lt; n<span class="p">;</span> j++<span class="o">)</span>
         ^
</code></pre></div></div>

<p>하지만, 대부분의 머신에서 최신 벡터 연산을 지원하지는 않기 때문에, 컴파일러는 기본적으로 아주 보수적인 연산만을 적용한다. 그래서 컴파일 시에 추가적인 플래그를 통해서 구체적으로 어떤 연산을 적용할지를 알려줄 수 있다.</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">-mavx</code>: 인텔의 AVX 벡터 연산 사용</li>
  <li><code class="language-plaintext highlighter-rouge">-mavx2</code>: 인텔의 AVX2 벡터 연산 사용</li>
  <li><code class="language-plaintext highlighter-rouge">-mfma</code>: FMA 연산 사용</li>
  <li><code class="language-plaintext highlighter-rouge">-march=&lt;string&gt;</code>: <code class="language-plaintext highlighter-rouge">&lt;string&gt;</code>으로 명세한 아키텍쳐에서 사용 가능한 연산이면 뭐든지 사용</li>
  <li><code class="language-plaintext highlighter-rouge">-march=native</code>: 해당 아키텍쳐에서 컴파일할 때 사용 가능한 연산이면 뭐든지 사용</li>
</ul>

<p>참고로 부동 소수점 연산의 제약으로 인해서, 이런 벡터화 플래그를 켤 때에는 <code class="language-plaintext highlighter-rouge">-ffast-math</code> 같은 다른 추가적인 플래그가 필요할 수도 있다.</p>

<p>아무튼, 버전 9까지 최적화한 코드에 <code class="language-plaintext highlighter-rouge">-march=native -ffast-math</code> 옵션을 주면 또 한번 극적으로 성능이 두 배 가량 증가해서 0.7초, Peak GFLOPS의 <strong>23.48%</strong>를 달성한다.</p>

<h3 id="버전-10-avx-intrinsics">버전 10: AVX Intrinsics</h3>
<p>인텔은 AVX 하드웨어 벡터 연산을 엔지니어가 직접 다룰 수 있도록 하는 C 스타일의 API를 제공하는데 이를 AVX Intrinsic Instructions라고 한다. 버전 9까지 얹은 최적화에다가 계속해서 다음과 같은 것들을 적용하고, 측정하고, 개선하고, 실험하는 등 성능을 엔지니어링할 수 있다.</p>
<ul>
  <li>데이터 전처리: 곱셈을 수행하기 전에 데이터를 미리 정규화하거나, 차원을 축소하거나, 메모리 레이아웃을 최적화 해볼 수 있다.</li>
  <li>행렬 전치 연산: 별 생각없이 구현하면, 행렬은 행 우선 (row-major) 순서로 메모리에 저장된다. 그러면 행렬의 곱셈 \(C = A \times B\)에서, 뒤에 곱해지는 행렬 B는 열 우선 (column-major) 순서로 접근하게 되므로 공간 지역성이 깨진다. 그래서 B를 미리 전치(Transpose)한 다음 곱할 때 접근 순서를 잘 고려하면 각 행을 순차적으로 접근할 수 있다.</li>
  <li>데이터 정렬 (Alignment): 앞에서 적용한 타일링을 최대한 적용할 수 있도록 행렬 내부를 블록 단위로 재배치 해볼 수 있다.</li>
  <li>메모리 관리 최적화: 행렬 크기가 커지면 계산에 필요한 메모리 양도 많아지는데, 이를 효율적으로 처리하기 위해서 메모리 풀, 제자리 연산, 메모리를 페이지 경계에 정렬하기 등을 고려해볼 수 있다.</li>
  <li>AVX Intrinsics를 적용해서 기저 조건 함수 개선: 기저 조건인 <code class="language-plaintext highlighter-rouge">mm_base</code>에서 단순한 반복문을 쓰는게 아니라 직접 AVX Intrinsics를 명시적으로 사용할 수 있다.</li>
</ul>

<p>특히, 버전 9에서 <code class="language-plaintext highlighter-rouge">-Rpass=vector</code> 레포트 결과를 보면 벡터화의 크기(width)가 2 밖에 안되었다. 하지만 우리는 직접 AVX Intrinsics을 이용해서 256 비트의 벡터, 즉 4개의 배정밀도 부동 소수점 연산을 다음과 같이 한번에 처리할 수 있다. (참고로 아래 코드는 n이 4의 배수일 때를 가정한 코드이다)</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">#include</span> <span class="cpf">&lt;immintrin.h&gt;</span><span class="cp">
</span>
<span class="kt">void</span> <span class="nf">mm_base</span><span class="p">(</span>
  <span class="kt">double</span> <span class="o">*</span><span class="kr">restrict</span> <span class="n">C</span><span class="p">,</span> <span class="kt">int</span> <span class="n">n_C</span><span class="p">,</span>
  <span class="kt">double</span> <span class="o">*</span><span class="kr">restrict</span> <span class="n">A</span><span class="p">,</span> <span class="kt">int</span> <span class="n">n_A</span><span class="p">,</span>
  <span class="kt">double</span> <span class="o">*</span><span class="kr">restrict</span> <span class="n">B</span><span class="p">,</span> <span class="kt">int</span> <span class="n">n_B</span><span class="p">,</span>
  <span class="kt">int</span> <span class="n">n</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">k</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">k</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span> <span class="n">k</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
      <span class="c1">// A[i][k]를 브로드캐스트할 벡터 준비.</span>
      <span class="c1">// 스칼라 값을 벡터 레지스터 4개 레인에 브로드캐스트 한다.</span>
      <span class="n">__m256d</span> <span class="n">a_vec</span> <span class="o">=</span> <span class="n">_mm256_set1_pd</span><span class="p">(</span><span class="n">A</span><span class="p">[</span><span class="n">n_A</span> <span class="o">*</span> <span class="n">i</span> <span class="o">+</span> <span class="n">k</span><span class="p">]);</span>

      <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">j</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">j</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span> <span class="n">j</span> <span class="o">+=</span> <span class="mi">4</span><span class="p">)</span> <span class="p">{</span>
        <span class="c1">// B의 원소 4개 연속 로드.</span>
        <span class="c1">// 정렬되지 않은 메모리에서, 4개의 배정밀도 부동 소수점으로 로드한다.</span>
        <span class="n">__m256d</span> <span class="n">b_vec</span> <span class="o">=</span> <span class="n">_mm256_loadu_pd</span><span class="p">(</span><span class="o">&amp;</span><span class="n">B</span><span class="p">[</span><span class="n">n_B</span> <span class="o">*</span> <span class="n">k</span> <span class="o">+</span> <span class="n">j</span><span class="p">]);</span>

        <span class="c1">// C의 원소 4개 연속 로드. 마찬가지.</span>
        <span class="n">__m256d</span> <span class="n">c_vec</span> <span class="o">=</span> <span class="n">_mm256_loadu_pd</span><span class="p">(</span><span class="o">&amp;</span><span class="n">C</span><span class="p">[</span><span class="n">n_C</span> <span class="o">*</span> <span class="n">i</span> <span class="o">+</span> <span class="n">j</span><span class="p">]);</span>

        <span class="c1">// FMA 시도 - c_vec += (a_vec * b_vec)</span>
        <span class="n">c_vec</span> <span class="o">=</span> <span class="n">_mm256_fmadd_pd</span><span class="p">(</span><span class="n">a_vec</span><span class="p">,</span> <span class="n">b_vec</span><span class="p">,</span> <span class="n">c_vec</span><span class="p">);</span>

        <span class="c1">// 결과 저장. 4개의 배정밀도 부동 소수점을 동시에 저장한다.</span>
        <span class="n">_mm256_storeu_pd</span><span class="p">(</span><span class="o">&amp;</span><span class="n">C</span><span class="p">[</span><span class="n">n_C</span> <span class="o">*</span> <span class="n">i</span> <span class="o">+</span> <span class="n">j</span><span class="p">],</span> <span class="n">c_vec</span><span class="p">);</span>
      <span class="p">}</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>위의 항목 중에서 정확히 어떤 걸 적용해서 실험했는지는 슬라이드에 나와있지 않지만 (아마도 마지막 항목은 적용했을 거라고 생각됨), 이것들을 다 개선하면 또 한번의 큰 폭의 성능 개선을 얻어서 최종적으로는 <strong>0.39초</strong>, 최대 성능의 약 <strong>42.15%</strong>에 도달할 수 있다.</p>

<h3 id="버전-11-인더스트리-라이브러리">버전 11: 인더스트리 라이브러리</h3>
<p>사실 여기까지 노력했던 모든 최적화들은 인더스트리에서 쓰이는 행렬 라이브러리에 다 녹아 있다. 예를 들어, 인텔의 MKL (Math Kernel Library)에는 이 모든 최적화들이 다 적용되어 있다.</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">#include</span> <span class="cpf">&lt;stdio.h&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;stdlib.h&gt;</span><span class="cp">
#include</span> <span class="cpf">"mkl.h"</span><span class="cp">
</span>
<span class="cp">#define SIZE_MATRIX 4096
#define SIZE_TILING 32
</span>
<span class="kt">int</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
  <span class="kt">double</span> <span class="o">*</span><span class="n">A</span><span class="p">,</span> <span class="o">*</span><span class="n">B</span><span class="p">,</span> <span class="o">*</span><span class="n">C</span><span class="p">;</span>

  <span class="c1">// 64-byte alignment</span>
  <span class="n">A</span> <span class="o">=</span> <span class="p">(</span><span class="kt">double</span> <span class="o">*</span><span class="p">)</span> <span class="n">mkl_malloc</span><span class="p">(</span><span class="n">SIZE_MATRIX</span> <span class="o">*</span> <span class="n">SIZE_MATRIX</span> <span class="o">*</span> <span class="k">sizeof</span><span class="p">(</span><span class="kt">double</span><span class="p">),</span> <span class="mi">64</span><span class="p">);</span>
  <span class="n">B</span> <span class="o">=</span> <span class="p">(</span><span class="kt">double</span> <span class="o">*</span><span class="p">)</span> <span class="n">mkl_malloc</span><span class="p">(</span><span class="n">SIZE_MATRIX</span> <span class="o">*</span> <span class="n">SIZE_MATRIX</span> <span class="o">*</span> <span class="k">sizeof</span><span class="p">(</span><span class="kt">double</span><span class="p">),</span> <span class="mi">64</span><span class="p">);</span>
  <span class="n">C</span> <span class="o">=</span> <span class="p">(</span><span class="kt">double</span> <span class="o">*</span><span class="p">)</span> <span class="n">mkl_malloc</span><span class="p">(</span><span class="n">SIZE_MATRIX</span> <span class="o">*</span> <span class="n">SIZE_MATRIX</span> <span class="o">*</span> <span class="k">sizeof</span><span class="p">(</span><span class="kt">double</span><span class="p">),</span> <span class="mi">64</span><span class="p">);</span>

  <span class="c1">//</span>
  <span class="c1">// initialise matrices with randome values...</span>
  <span class="c1">//</span>

  <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">tj</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">tj</span> <span class="o">&lt;</span> <span class="n">SIZE_MATRIX</span><span class="p">;</span> <span class="n">tj</span> <span class="o">+=</span> <span class="n">SIZE_TILING</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">ti</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">ti</span> <span class="o">&lt;</span> <span class="n">SIZE_MATRIX</span><span class="p">;</span> <span class="n">ti</span> <span class="o">+=</span> <span class="n">SIZE_TILING</span><span class="p">)</span> <span class="p">{</span>
      <span class="k">for</span> <span class="p">(</span><span class="n">tk</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">tk</span> <span class="o">&lt;</span> <span class="n">k</span><span class="p">;</span> <span class="n">tk</span> <span class="o">+=</span> <span class="n">SIZE_TILING</span><span class="p">)</span> <span class="p">{</span>
        <span class="kt">int</span> <span class="n">tile_m</span> <span class="o">=</span> <span class="p">(</span><span class="n">ti</span> <span class="o">+</span> <span class="n">SIZE_TILING</span> <span class="o">&gt;</span> <span class="n">SIZE_MATRIX</span><span class="p">)</span> <span class="o">?</span> <span class="p">(</span><span class="n">SIZE_MATRIX</span> <span class="o">-</span> <span class="n">ti</span><span class="p">)</span> <span class="o">:</span> <span class="n">SIZE_TILING</span><span class="p">;</span>
        <span class="kt">int</span> <span class="n">tile_n</span> <span class="o">=</span> <span class="p">(</span><span class="n">tj</span> <span class="o">+</span> <span class="n">SIZE_TILING</span> <span class="o">&gt;</span> <span class="n">SIZE_MATRIX</span><span class="p">)</span> <span class="o">?</span> <span class="p">(</span><span class="n">SIZE_MATRIX</span> <span class="o">-</span> <span class="n">tj</span><span class="p">)</span> <span class="o">:</span> <span class="n">SIZE_TILING</span><span class="p">;</span>
        <span class="kt">int</span> <span class="n">tile_k</span> <span class="o">=</span> <span class="p">(</span><span class="n">tk</span> <span class="o">+</span> <span class="n">SIZE_TILING</span> <span class="o">&gt;</span> <span class="n">SIZE_MATRIX</span><span class="p">)</span> <span class="o">?</span> <span class="p">(</span><span class="n">SIZE_MATRIX</span> <span class="o">-</span> <span class="n">tk</span><span class="p">)</span> <span class="o">:</span> <span class="n">SIZE_TILING</span><span class="p">;</span>
        <span class="c1">// 첫번째 반복일때만 0, 이후는 누적(1.0)</span>
        <span class="kt">double</span> <span class="n">beta_vec</span> <span class="o">=</span> <span class="p">(</span><span class="n">tk</span> <span class="o">==</span> <span class="mi">0</span><span class="p">)</span> <span class="o">?</span> <span class="mi">0</span><span class="p">.</span><span class="mi">0</span> <span class="o">:</span> <span class="mi">1</span><span class="p">.</span><span class="mi">0</span><span class="p">;</span>

        <span class="n">cblas_dgemm</span><span class="p">(</span>
          <span class="n">CblasRowMajor</span><span class="p">,</span>
          <span class="n">CblasNoTrans</span><span class="p">,</span>
          <span class="n">CblasNoTrans</span><span class="p">,</span>
          <span class="n">tile_m</span><span class="p">,</span>
          <span class="n">tile_n</span><span class="p">,</span>
          <span class="n">tile_k</span><span class="p">,</span>
          <span class="n">alpha</span><span class="p">,</span>
          <span class="n">B</span> <span class="o">+</span> <span class="n">ti</span> <span class="o">*</span> <span class="n">k</span> <span class="o">+</span> <span class="n">tk</span><span class="p">,</span>            <span class="c1">// B의 타일 시작 위치</span>
          <span class="n">SIZE_MATRIX</span><span class="p">,</span>                <span class="c1">// B의 leading dimension</span>
          <span class="n">A</span> <span class="o">+</span> <span class="n">tk</span> <span class="o">*</span> <span class="n">SIZE_MATRIX</span> <span class="o">+</span> <span class="n">tj</span><span class="p">,</span>  <span class="c1">// A의 타일 시작 위치</span>
          <span class="n">SIZE_MATRIX</span><span class="p">,</span>                <span class="c1">// A의 leading dimension</span>
          <span class="n">beta_vec</span><span class="p">,</span>
          <span class="n">C</span> <span class="o">+</span> <span class="n">ti</span> <span class="o">*</span> <span class="n">SIZE_MATRIX</span> <span class="o">+</span> <span class="n">tj</span><span class="p">,</span>  <span class="c1">// C의 타일 시작 위치</span>
          <span class="n">SIZE_MATRIX</span><span class="p">);</span>               <span class="c1">// C의 leading dimension</span>
      <span class="p">}</span>
    <span class="p">}</span>
  <span class="p">}</span>

  <span class="n">mkl_free</span><span class="p">(</span><span class="n">A</span><span class="p">);</span>
  <span class="n">mkl_free</span><span class="p">(</span><span class="n">B</span><span class="p">);</span>
  <span class="n">mkl_free</span><span class="p">(</span><span class="n">C</span><span class="p">);</span>

  <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>이 코드는 0.41초, 최대 성능의 <strong>40.10%</strong>를 보여준다. 우리가 열심히 최적화를 쌓아 온 버전 10과 비교했을 때 오차 범위 이내의 성능을 보여준다.</p>

<h3 id="정리-및-최종-결론">정리 및 최종 결론</h3>
<p>최종적으로 이때까지 진행한 모든 최적화들과 성능들을 한 표로 정리하면 다음과 같다.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: right">버전</th>
      <th>구현 방법</th>
      <th style="text-align: right">실행 시간 (초)</th>
      <th style="text-align: right">최대 성능 대비 몇 %?</th>
      <th style="text-align: right">물리적인 최대 성능만 고려 시 몇 %?</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: right">1</td>
      <td>파이썬</td>
      <td style="text-align: right"><strong>21,042</strong></td>
      <td style="text-align: right"><strong>0.00078%</strong></td>
      <td style="text-align: right"><strong>0.00156%</strong></td>
    </tr>
    <tr>
      <td style="text-align: right">2</td>
      <td>자바</td>
      <td style="text-align: right">2,738</td>
      <td style="text-align: right">0.006%</td>
      <td style="text-align: right">0.012%</td>
    </tr>
    <tr>
      <td style="text-align: right">3</td>
      <td>C</td>
      <td style="text-align: right">1,156</td>
      <td style="text-align: right">0.014%</td>
      <td style="text-align: right">0.028%</td>
    </tr>
    <tr>
      <td style="text-align: right">4</td>
      <td>+ 반복문 중첩 순서 바꾸기</td>
      <td style="text-align: right">177.68</td>
      <td style="text-align: right">0.093%</td>
      <td style="text-align: right">0.186%</td>
    </tr>
    <tr>
      <td style="text-align: right">5</td>
      <td>+ 컴파일러 최적화</td>
      <td style="text-align: right">54.63</td>
      <td style="text-align: right">0.30%</td>
      <td style="text-align: right">0.60%</td>
    </tr>
    <tr>
      <td style="text-align: right">6</td>
      <td>병렬 처리</td>
      <td style="text-align: right">3.18</td>
      <td style="text-align: right">5.17%</td>
      <td style="text-align: right">10.34%</td>
    </tr>
    <tr>
      <td style="text-align: right">7</td>
      <td>+ 고정 크기 타일링</td>
      <td style="text-align: right">1.74</td>
      <td style="text-align: right">9.45%</td>
      <td style="text-align: right">18.9%</td>
    </tr>
    <tr>
      <td style="text-align: right">8</td>
      <td>병렬로 분할정복</td>
      <td style="text-align: right">1.30</td>
      <td style="text-align: right">12.65%</td>
      <td style="text-align: right">25.30%</td>
    </tr>
    <tr>
      <td style="text-align: right">9</td>
      <td>+ 컴파일러 최적화 (Vectorization)</td>
      <td style="text-align: right">0.7</td>
      <td style="text-align: right">23.48%</td>
      <td style="text-align: right">46.96%</td>
    </tr>
    <tr>
      <td style="text-align: right">10</td>
      <td>+ AVX Intrinsics</td>
      <td style="text-align: right"><strong>0.39</strong></td>
      <td style="text-align: right"><strong>42.15%</strong></td>
      <td style="text-align: right"><strong>84.30%</strong></td>
    </tr>
    <tr>
      <td style="text-align: right">11</td>
      <td>Intel MKL</td>
      <td style="text-align: right">0.41</td>
      <td style="text-align: right">40.10%</td>
      <td style="text-align: right">80.20%</td>
    </tr>
  </tbody>
</table>

<p>아주 간단하지만 굉장히 느렸던 버전 1의 파이썬 구현부터, 하드웨어 및 소프트웨어가 어떻게 동작하는지를 이해하고 행렬 곱셈 문제 자체의 특징을 고려하여 다양한 아이디어를 직접 구현하고, 관련된 파라미터를 튜닝하고, 실험하고, 측정하고, 이런 이터레이션을 반복하면서 점진적으로 개선한 끝에 버전 10에 와서는 인더스트리 라이브러리에 맞먹는 성능을 낼 수 있었다. 당장 6시간 걸리던 연산이 0.4초만에 끝난다고 상상해보면 정말 엄청난 개선이다. 심지어, 앞에서 최대 성능을 계산할 때 2-way 하이퍼스레딩을 고려해야 하는지에 대해서 개인적인 의문이 있었는데, 만약 이걸 고려하지 않는다면, 표의 마지막 컬럼에서 보듯이 성능 엔지니어링을 통해 물리적인 머신의 최대 성능의 84.30%를 뽑아내었다.</p>

<p>물론 이정도의 극적인 성능 개선은 좀처럼 찾아보기 힘들다고 한다. 그만큼 행렬 곱셈이 많이 연구된 분야이면서, 또 이런 다양한 최적화를 적용해볼 수 있는 좋은 문제라는 생각이 든다. 여기서 적용한 다양한 엔지니어링 방법들을 다른 문제에 적용해볼 수 있는 기회가 있으면 좋겠다.</p>
<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1">
      <p>캐시 성능을 측정하는 게 아니라 시뮬레이션하는 이유는 현대 하드웨어와 그 실행 환경의 복잡성으로 인한 한계 때문이다. 실제 CPU의 성능 카운터로는 모든 캐시 동작을 정확하게 기록할 수 없다. 그리고 프로그램이 실행되는 환경, 예를 들어 prefetch나 speculation 같은 하드웨어 측면의 간섭과, OS의 스케쥴링, 백그라운드 프로세스 등 소프트웨어 측면의 간섭으로 인해서 정확한 측정이 불가능하다. 그래서 Cachegrind는 프로그램을 직접 실행하긴 하면서 가상의 캐시 모델을 기반으로 캐시 동작을 시뮬레이션 한다. 프로그램의 모든 메모리 접근을 추적하고, 이게 진짜 캐시에 어떻게 반영될지를 계산하는 것이다. <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:2">
      <p>참고로, Cachegrind는 디폴트로 L1, L2, L3의 여러 개의 캐시 중 LL (Last Level)을 중심으로 캐시 미스율을 계산한다. 실제로는 L1, L2, L3를 모두 시뮬레이션 하지만, CPU와 메인 메모리 사이의 마지막 관문이 바로 이 LLC라서 이걸 넘어가는 순간 성능 저하가 매우 크기 때문에 가장 중요한 지표라고 볼 수 있다. <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>sangwoo-joh</name></author><category term="cs" /><category term="essay" /><summary type="html"><![CDATA[목차]]></summary></entry><entry><title type="html">먼저 온 미래</title><link href="https://blog.untyped.kr/the-future-that-arrived-first" rel="alternate" type="text/html" title="먼저 온 미래" /><published>2025-10-20T00:00:00+00:00</published><updated>2025-10-20T00:00:00+00:00</updated><id>https://blog.untyped.kr/the-future-that-arrived-first</id><content type="html" xml:base="https://blog.untyped.kr/the-future-that-arrived-first"><![CDATA[<p>친구의 추천으로 장강명 작가님의 “먼저 온 미래”라는 책을 읽었다. 알파고의 등장은 바둑계에 큰 충격을 주었는데, 그 이후에 바둑계가 어떻게 변했는지, 특히 바둑기사들이 AI에 어떻게 적응하였는지를 취재하고, 거기에 작가의 생각을 흥미롭게 엮은 좋은 책이었다. 무엇보다 책 제목을 영리하게 잘 지었다는 생각이 들었다. 장강명이라는 작가에 대해서도 이번에 처음 알게 되었는데, 과거 기자 출신이라 그런지 취재에 군더더기가 없었고 글이 논리적으로 잘 짜여져 있어 참 읽기 쉬운 글을 쓰는 작가라고 느꼈다. 별 거 아닌 블로그 글을 쓰면서도 다른 사람이 (하물며 미래의 나에게도) 읽기 쉽게 쓰는게 얼마나 어려운 일인지 절절하게 깨닫고 있는데, 정말 대단하다.</p>

<p>책 자체도 여러모로 생각할 거리를 많이 던져주었다. 흥미롭게 읽은 부분을 빨간 줄 치면서 읽었는데 너무 많아서, 다시 되새기고 싶은 인상 깊었던 부분만 기록해두려고 한다.</p>

<hr />

<blockquote>
  <p>그런데 설사 터미네이터를 막고 일자리는 지키더라도 어떤 인간적 가치들은 그 과정에서 틀림없이 부서질 것이다. 사실 그런 인간적 가치를 무너뜨리는 데에는 그리 대단한 성능의 인공지능이 필요하지도 않다. 그리고 우리는 그런 파괴가 일어난 뒤에야 그 가치들의 정체를 뒤늦게 알아차릴 가능성이 높다. … 소설을 쓰는 데 필요한 게 창의성이든 문학성이든 뭐든 간에, 그걸 인간만 가질 수 있다고 말할 근거는 아무것도 없다. 알파고가 주는 교훈이 바로 그것이다. 우리가 막연하게 ‘그건 불가능할 거야’라고 생각한다고 해서 실제로 불가능한 것은 아니다. 불가능한 것은 매우 적다.</p>
</blockquote>

<p>앞부분에서 가장 공감되면서도 무서운 문단이었다. 요즘 LLM의 발전을 보고 있으면 “그리 대단한 성능의 인공지능”이 아니더라도 충분히 인간적 가치를 무너뜨릴 수 있다는 말이 무엇인지 실감한다.</p>

<blockquote>
  <p>“옛날 기보들을 사람들이 인공지능에 입력하기도 해요. 기사들이 피를 토하면서 처절하게 뒀다는 전설의 대국들이 있거든요. 그런데 그렇게 피를 토하면서 발견했다는 신수[이전에는 잘 두어지지 않았지만 검토 결과 좋다고 검증된 수]를 인공지능에 넣었더니 이길 확률이 5퍼센트 떨어지는 수였고 그렇더라고요.”</p>
</blockquote>

<blockquote>
  <p>“… 바둑이 싫어진 건 아니고, 바둑을 좀 잃어버린 기분이에요. 내 마음대로 생각하고 내가 그릴 수 있는 그림을 뺏겨버린 느낌.”</p>
</blockquote>

<blockquote>
  <p>“답을 모르는 상태에서 스스로 연구하던 것과 AI가 정답을 알려주는 상태에서 연구하는 건 다르죠.”</p>
</blockquote>

<blockquote>
  <p>긍지와 관련된 문제다. 사람은 의미 있는 일을 자신이 잘해내고 있다고 믿을 때 긍지를 얻는다. 나는 다른 직업에서도 인공지능으로 인해 긍지를 잃을 사람이 많아지지라 생각한다. 인공지능은 우리 예상보다 훨씬 넓은 영역에서 어떤 일의 의미와 인간의 유능함을 납작하게 짓눌러 버릴 것이다.</p>
</blockquote>

<p>급격한 발전은 혼란을 낳는다. 정확한 정의를 할 순 없지만 인간적인 가치라는 것을 건드릴 때에는 더더욱 그렇다. 과거 산업혁명 때 장인들이 러다이트 운동을 일으킨 심정을 알 것 같다. 그때를 물리적 노동의 산업혁명이라고 본다면 지금은 지적 노동의 산업혁명의 문턱에 있지 않을까?</p>

<blockquote>
  <p>“… 초기에는 AI와 대국을 많이 했는데 그렇게 좋은 방법이 아니라는 생각이 들었어요. 이후에는 AI의 수를 제 생각과 계속 비교하는 공부법을 선택했습니다. 지금은 다른 기사들도 대개 그 방법으로 공부하는 것 같고, AI와 대국하는 기사도 조금 있는 거 같아요.”</p>
</blockquote>

<blockquote>
  <p>신진서 9단은 AI 추천수와 자신의 생각을 비교하는 방법이 굉장히 재미가 없는 학습법이라고 설명했다. 정신적으로 소모되는 느낌이 크다고 했다. 게다가 그는 엄청나게 성실한 기사다. 언론 인터뷰에서 “나보다 더 노력한 기사는 있을지 몰라도 나보다 더 힘들게 공부한 기사는 없을 것”이라고 말한 적도 있다. “… 제가 하루에 12시간씩 공부를 1년 정도 했는데 효과가 없었다면 그만두고 휴식도 하고 여행도 가고 그랬겠죠. 그런데 AI를 통해서 발전하고 성장할 수 있다는 확신이 있어요. AI로 실력을 연마하다 보면 100퍼센트 지금보다 더 높은 경지에 이를 수 있다고 믿어서 연구에 집중할 수 있습니다.” … 신진서 9단은 공부하면 공부할수록 학습 도구로서 인공지능이 대단하다고 느낀다고 했다.</p>
</blockquote>

<blockquote>
  <p>다른 분야도 마찬가지다. ‘인공지능이 그 분야에 어떤 영향을 미칠 것인가’같은 고민은, 실제로 그 분야에서 쓸 만한 인공지능이 나오기 전까지만 할 수 있다. 인공지능은 모든 분야에서 게임체인저가 된다. 인공지능이 등장하면 그 분야의 규칙 자체가 바뀌며, 그때부터 해야 하는 고민은 ‘이 인공지능을 어떻게 활용할 것인가’가 된다. 어쨌든 경쟁은 다른 사람과 하는 거니까.</p>
</blockquote>

<p>산업혁명과의 가장 큰 차이점이라면, 인공지능은 개인의 수련에 도움이 된다는 점이 아닐까. 우리는 인공지능과의 공부를 통해 더 성장할 수 있다. 피할 수 없다면 활용해야 한다.</p>

<blockquote>
  <p>나는 다른 업계에서도 같은 일이 벌어지리라 생각한다. 인공지능과 같은 강력한 신기술은 기존의 권력관계를 뒤흔든다. 만약 그것이 기득권의 힘을 약화시키고 주변부에 있던 그룹에게 기회를 제공한다면, 그 새로운 기술은 적어도 특정 집단으로부터는 열렬한 환영을 받을 것이다. 인쇄술은 성서 해석을 독점하던 교회의 권력을 약화시켰고, 지식인 집단의 규모와 힘을 키우는 데 엄청난 영향을 미쳤다. 오늘날 지식인 중에서 인쇄술을 부정적으로 보는 사람은 없을 것이다. 인터넷과 소셜미디어는 뉴스를 독점하던 종이 신문과 지상파 방송의 권력을 약화시켰다. 인터넷 언론과 ‘대안 매체’ 종사자들, 블로거들은 그런 변화를 긍정적인 것으로 평가한다.</p>
</blockquote>

<p>나한테도 비슷한 생각이 스쳐지났던 적이 있다. 인터넷과 유튜브가 수많은 학습자료의 권력을 무너뜨렸지만 그건 일방향이었다. LLM은 양방향 소통이 가능하다. 원하는 주제의 키워드만 적절히 입력한다면 많은 것을 알아낼 수 있다. 사실 키워드를 정확하게 몰라도, 두루뭉실하게 근접한 설명을 하기만 해도, 얼추 맞는 키워드를 유추하기까지 한다. 전에 없던 강력한 도구다.</p>

<blockquote>
  <p>‘인공지능은 그저 도구일 뿐이며, 사용 여부는 각자 선택하면 되고, 사용하건 사용하지 않건 각자가 추구하는 가치를 지켜나가면 된다’ 같은 말을 하는 사람을 본다. 그들의 순진한 전망은 틀렸다. 인공지능을 사용하지 않더라도, 인공지능을 사용하는 다른 사람들 때문에 내가 추구하는 가치가 변하고 뒤바뀐다. 나를 둘러싼 기술-환경이 바뀌기 때문이다. 내가 더불어 살아가는 한 그 영향을 받는다.</p>
</blockquote>

<blockquote>
  <p>알파고 이전 바둑 팬들은 일류 기사들의 대국을 보다 이해가 가지 않는 수가 나오면 존경심을 품고 ‘저 기사는 왜 저 자리에 돌을 둔 걸까’하며 고심했다. 이제는 AI 추천수와 비교하며 ‘저 양반은 꼭 중반에 저런 실수를 잘 하더라’하고 품평한다.</p>
</blockquote>

<p>참 뭐랄까… 서글프면서도 어쩔 수 없는 건가 싶다. 그저 적응하는 단계일까. 아니면 정말로 우리가 지키던 소중한 무언가가 부서지고 있는 것일까. 아직은 모르겠다.</p>

<blockquote>
  <p>암묵지는 많은 인간 전문가에게 단순히 그들이 보유한 지식 상품이 아니라, 자기효능감과 자부심, 자존김의 근원이기도 하다. … 그런데 딥러닝 기법을 사용하는 인공지능은 인간 전문가들보다 더 풍성하고 정확한 암묵지를 지니게 될지 모른다.</p>
</blockquote>

<p>암묵지. LLM의 그 방대한 학습량을 이렇게 적절한 단어로 표현할 수 있다니 놀랍다.</p>

<blockquote>
  <p>인류학자 데이비드 그레이버는 2013년 ‘불쉿 작업(bullshit job)’이라는 말을 만들어 냈고, 몇 년 뒤에 그 개념으로 책을 썼다. 그레이버는 현대 사회에는 통째로 사라져도 세상이 조금도 달라지지 않을 직업, 종사자들조차 속으로는 쓸모없는 일이라고 여기는 ‘불쉿 작업’이 많다고 주장한다. … 보수와 처우가 괜찮고 노동 강도가 높지 않은데도 의미가 없는 일이라면 불쉿 작업이다. … 전체 일자리의 40 퍼센트에 육박하며 현대 사회의 몇 가지 구조적 원인 때문에 점점 늘어나는 중이라고 한다.</p>
</blockquote>

<blockquote>
  <p>나는 AI 시대가 공허의 시대가 될지도 모르겠다고 상상한다. 평범한 인간들이 가치를 잃어버리고, 가치로부터 소외되는. 현대인은 종교로부터 멀어지면서 인간 외부에 객관적 가치가 있다는 믿음에서 멀어졌다. 현대 주류 경제학이 노동가치설을 폐기하면서 우리는 어떤 일에 내재적 가치가 있다는 믿음에서도 멀어졌다. 이제 무신론자와 자유시장주의자가 함께 합의할 수 있는 가치는 시장 가격인데, 그것은 도덕적 규범이나 사회적 가치와는 상관없는 개념이다. 이제 우리는 가치가 없다고 느끼는 일을 하면서도 적당한 급여를 받을 때, 그 일에 왜 가치가 없다고 느끼는지 잘 설명하지 못한다.</p>
</blockquote>

<p>공허의 시대가 오면 오히려 육체적인 것과 전통적인 것의 가치가 높아질 수도 있겠다는 생각이 들었다. 지금도 AI가 발전하면서 지식노동과 예술업계를 잡아 먹으며 오히려 기존에 빠르게 대체될 거라고 예상했던 블루칼라의 위상이 높아지고 있으니. 지적 노동은 AI와 소수의 선택된 인간만이 하고 대다수의 사람들은 몸을 쓰는 일을 하는 그런 날이 오려나? 아니면 이것도 그저 한 인간의 선형적인 가벼운 예상에 불과하려나?</p>

<blockquote>
  <p>“저만 그런 건지 모르겠지만, 누구나 어떤 일에서 당대 최고가 될 수 있는게 아니잖아요. 제가 항상 최첨단에서 무언가를 할 수 있는 건 아니잖아요. 기계가 더 잘한다고 해서 왜 인간이 하면 안 되는 건지 모르겠어요.”</p>
</blockquote>

<blockquote>
  <p>어떤 일을 인공지능이 인간보다 더 잘할 수 있다면 그 일을 둘러싼 사회적 맥락이 바뀌며, 그 일이 우리에게 주는 재미도 바뀔 것 같다. 재미가 완전히 사라지지 않더라도 말이다.</p>
</blockquote>

<blockquote>
  <p>기묘하게도 이 논의를 오래 할수록 우리가 인공지능만큼이나 재미에 대해서도 아는 게 없다는 데 생각이 이르게 된다. 대체 재미라는 게 뭘까? 무엇이 재미있는 것이고, 재미에 영향을 미치는 요소는 무엇일까?</p>
</blockquote>

<blockquote>
  <p>“… 그런데 사람들은 그렇게 실수하는 모습을 좋아하거든요. 완벽하게 두는 바둑이 아니라 스토리가 있는 바둑을 좋아하는 거죠.”</p>
</blockquote>

<blockquote>
  <p>사실 지금까지도 많은 사람이 예술가의 서사와 그들의 작품을 엄격하게 분리하지는 않는다. 빈센트 반 고흐의 비극적인 삶은 그가 그린 유화에 비장한 아름다움을 더해주고, 루트비히 판 베토벤의 청각장애와 제9번 교황곡의 장엄함도 한 덩어리다. 반대로 뛰어난 예술가가 나치에 부역했다든가, 인종차별주의자였다는 사실이 뒤늦게 밝혀지면 그의 작품도 매력을 잃는다.</p>
</blockquote>

<p>읽으면서 절로 고개를 끄덕거리게 만드는 이런 생각을 매끄럽게 풀어내는 것이 바로 좋은 책의 매력인 것 같다. 지금도 우리의 육체보다 훨씬 더 기계가 잘하는 게 많지만 그럼에도 여전히 우리는 운동을 한다. 그렇지만 과거만큼의 파급력은 적겠지. 그리고 스토리. 꽤 지난 일이지만 즐겨 듣던 가수가 정말 실망스러운 일을 저지르는 것을 지켜보면서 그의 노래와 가삿말에 매력을 잃어버렸고 더 이상 그 가수의 노래를 찾지 않게 되었다. 사람은 참 감성적이다. 이야기는 중요하다.</p>

<blockquote>
  <p>살아 있는 사람의 사적인 얘기는 유튜브와 소셜미디어에서 가장 인기 있는 콘텐츠이기도 하다. … 그런 때 인공지능이 팔 수 없는 걸 내가 팔 수 있다면 든든하리라. 그리고 내 머리에는 나만이 팔 수 있는 상품으로 ‘내 사생활’이라는 답이 떠오른다. … AI 시대에 예술가들은 자신이 작품을 만드는 과정으로 이야기를 잘 만드는 기술과 그 자신을 교묘하게 상품화하여 판매하는 방법을 배워야 할지도 모르겠다.</p>
</blockquote>

<p>내가 평소에 하던 생각과 너무나도 똑같아서 놀란 부분이다. 내가 “내 사생활”이라는 상품을 팔게 되는 날이 오지 않기를 바란다.</p>

<blockquote>
  <p>우리에게 필요한 것은 외로움을 견디는 힘이다. 외로움을 견디는 힘을 가진 사람은 외로움을 통해 성장하고 건강해진다. 외로움을 견디는 힘을 지닌 사람은 보다 좋은 삶을 살 수 있고, 외로움을 견디는 힘을 모르는 사람은 좋은 삶을 살지 못한다. 사실 좋은 삶을 살려면 어느 정도의 외로움이 꼭 필요하다. 하루 24시간 내내 수백 명과 화상통화를 하는 사람은 좋은 삶으로부터 한참 떨어진 곳에 있다. 외로움을 견디는 힘이 있는 사람만이 외로움을 달래기 위해 다른 사람이나 사물에 의존하는 태도를 버릴 수 있다. 우리는 그 힘을 배워야 하고, 아이들에게 가르쳐야 한다. 그런데 우리는 지금 그런 공부를 하지 않고 있다.</p>
</blockquote>

<blockquote>
  <p>그러는 사이 통신 기술은 외로움을 견디는 바로 그 힘과 다른 사람과 건강하게 연결되는 그 방식 자체를 훼손하고 왜곡한다. 통신 기술은 외로움이라는 개념을 변질시켰다. 외로움은 이제 다른 사람들과 함께 있는다고 해서 풀리는 문제가 아니다. 외로움은 이제 탁하고 막연하게 편재하는 문제다. 그리고 우리는 그윽하고 감미로운 고독을 잃어버렸다.</p>
</blockquote>

<p>나 스스로는 외로움을 잘 견딘다기보다는 잘 인식하지 못하는 쪽에 가깝다고 생각한다. 그리고 외로움이라는 게 물리적으로 혼자 있는 것과는 완전히 별개의 이야기라는 것도 잘 이해하고 있다. 어쩔 수 없는 부분을 내가 이겨내려고 하는 순간 외로움에 지는 것 같다. 적당히 받아들이고 내려놓는 것도 필요하다. 언어의 한계로 잘 표현하지는 못하겠지만… 우리 아들이 이런 건강한 힘을 기르며 클 수 있도록 하려면 어떤 식으로 경험하게 해줘야 할지 고민이다.</p>

<blockquote>
  <p>우리는 과학기술이 가치중립적이라는 헛소리를 경계해야 한다. 과학기술은 물질세계뿐 아니라 정신세계 깊은 곳까지 힘을 미치는 강력한 권력이다. … 기술은 하나의 사상이다.</p>
</blockquote>

<p>이건 생각해보지 못했던 새로운 시각이었는데, 강대국들이 AI 발전을 이끌어나가는 지금은 이 권력의 무게가 남다르다고 느꼈다. 과연 이 세상이 어찌될지.</p>

<hr />

<p>사실 마음 한 구석에는 바둑계에서 어떤 정답이나 혹은 정답의 실마리를 찾아냈길 바랐던 것 같다. 하지만 역시나 은총알은 없었고, 대부분이 혼란한 가운데 중심을 잘 지켜낸 지혜로운 소수의 사람들이 있었을 뿐이다. 이제 더이상 AI라는 거대한 물결에 휩쓸리는 것을 막을 방도는 없어보인다. 지금 할 수 있는 일을 하자.</p>]]></content><author><name>sangwoo-joh</name></author><category term="life" /><category term="musing" /><summary type="html"><![CDATA[친구의 추천으로 장강명 작가님의 “먼저 온 미래”라는 책을 읽었다. 알파고의 등장은 바둑계에 큰 충격을 주었는데, 그 이후에 바둑계가 어떻게 변했는지, 특히 바둑기사들이 AI에 어떻게 적응하였는지를 취재하고, 거기에 작가의 생각을 흥미롭게 엮은 좋은 책이었다. 무엇보다 책 제목을 영리하게 잘 지었다는 생각이 들었다. 장강명이라는 작가에 대해서도 이번에 처음 알게 되었는데, 과거 기자 출신이라 그런지 취재에 군더더기가 없었고 글이 논리적으로 잘 짜여져 있어 참 읽기 쉬운 글을 쓰는 작가라고 느꼈다. 별 거 아닌 블로그 글을 쓰면서도 다른 사람이 (하물며 미래의 나에게도) 읽기 쉽게 쓰는게 얼마나 어려운 일인지 절절하게 깨닫고 있는데, 정말 대단하다.]]></summary></entry><entry><title type="html">가상 메모리 이야기</title><link href="https://blog.untyped.kr/virtual-memory" rel="alternate" type="text/html" title="가상 메모리 이야기" /><published>2025-10-17T00:00:00+00:00</published><updated>2025-10-17T00:00:00+00:00</updated><id>https://blog.untyped.kr/virtual-memory</id><content type="html" xml:base="https://blog.untyped.kr/virtual-memory"><![CDATA[<h2 class="no_toc" id="목차">목차</h2>

<ul id="markdown-toc">
  <li><a href="#페이지-크기-튜닝" id="markdown-toc-페이지-크기-튜닝">페이지 크기 튜닝</a></li>
  <li><a href="#cow-copy-on-write" id="markdown-toc-cow-copy-on-write">COW (Copy-on-Write)</a></li>
  <li><a href="#캐시-친화적인-프로그래밍" id="markdown-toc-캐시-친화적인-프로그래밍">캐시 친화적인 프로그래밍</a>    <ul>
      <li><a href="#tlb-지역성-활용" id="markdown-toc-tlb-지역성-활용">TLB 지역성 활용</a></li>
      <li><a href="#페이지-폴트를-줄이도록-힌트주기" id="markdown-toc-페이지-폴트를-줄이도록-힌트주기">페이지 폴트를 줄이도록 힌트주기</a></li>
      <li><a href="#메모리를-페이지-크기에-알맞게-할당하기" id="markdown-toc-메모리를-페이지-크기에-알맞게-할당하기">메모리를 페이지 크기에 알맞게 할당하기</a></li>
      <li><a href="#메모리-프리페치" id="markdown-toc-메모리-프리페치">메모리 프리페치</a></li>
    </ul>
  </li>
  <li><a href="#time-커맨드가-알려주는-것들" id="markdown-toc-time-커맨드가-알려주는-것들">time 커맨드가 알려주는 것들</a></li>
  <li><a href="#메모리-성능-모니터링하기" id="markdown-toc-메모리-성능-모니터링하기">메모리 성능 모니터링하기</a></li>
  <li><a href="#주소는-항상-짝수" id="markdown-toc-주소는-항상-짝수">주소는 항상 짝수</a></li>
</ul>

<p>모든 프로그램은 메모리가 필요하다.</p>

<p>현대 컴퓨터는 폰 노이만 아키텍쳐를 기반으로 한다. 모든 계산은 CPU에서 이루어지는데, 프로그램과 데이터는 메모리에 저장되어 있다. 그래서 계산을 하려면 이걸 레지스터까지 가져와야 한다. 혹은 반대로, CPU가 계산한 결과를 현실 세계에 반영하려면, 레지스터로부터 출발해서 메모리(혹은 디스크)까지 가지고 가야 한다. 이 과정을 더 효율적으로 하기 위해서 이 사이에 수많은 보조적인 단계가 생기게 되었고, 그 결과 우리가 아는 메모리 계층구조(Memory Hierarchy)가 탄생하게 되었다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CPU (register) &lt;-&gt; L1 Cache &lt;-&gt; L2 (&lt;-&gt; L3) &lt;-&gt; RAM &lt;-&gt; SSD or HDD
</code></pre></div></div>

<ul>
  <li><strong>레지스터</strong>: CPU 내부에 있어서 가장 속도가 빠르다. 폰 노이만 아키텍쳐에서는 데이터와 코드를 구분하지 않기 때문에, 레지스터는 데이터 뿐만 아니라 실행할 명령어(기계어)도 처리한다. 대신 용량이 매우 작아서 수백 바이트 수준이다. 속도는 1 싸이클 미만이다.</li>
  <li><strong>L1 캐시</strong>: CPU 코어에 내장되어 있고 캐시 중에서 가장 빠르다. 보통 명령어를 위한 캐시(Instruction Cache)와 데이터를 위한 캐시(Data Cache)가 따로 있다. 대충 32-128KB 정도의 크기를 가진다. 속도는 1-4 싸이클 정도.</li>
  <li><strong>L2 캐시</strong>: L1보다 용량은 크지만 좀 느린 캐시다. 역시 CPU 코어 근처에 있다. 용량은 256K-1MB 정도이고 속도는 10-20 싸이클 정도.</li>
  <li><strong>L3 캐시</strong>: 요건 선택사항이다. 멀티 코어 프로세서가 도입되면서 등장한 캐시로 여러 개의 코어가 공유하는 캐시다. 수십 MB 정도의 용량과 50-80 싸이클 정도의 속도를 가진다.</li>
  <li><strong>RAM</strong>: 여기부터는 이제 우리에게 익숙한 “메모리”다. RAM은 Random Access Memory의 줄임말이다. Random Access는 주소값만 알면 순차적인 접근 없이 데이터를 읽고 쓰고 할 수 있다는 뜻이다. 요즘은 수 GB에서 수 TB까지 다양하게 선택할 수 있다. 속도는 100-300 싸이클 정도 된다. 프로그램이 데이터를 “쓰는(write)” 최종 목적지 중 하나다.</li>
  <li><strong>저장장치</strong>: SSD, HDD 등으로 전원이 꺼져도 데이터를 유지한다(비휘발성). 용량은 가격에 따라 수십, 수백기가에서 수테라까지 다양하다. 속도는 SSD냐 HDD냐에 따라서도 크게 다르지만 보통 RAM보다 수백, 수천, 수만배는 느리다. 프로그램이 데이터를 쓰는 최종 목적지 중 하나다.</li>
</ul>

<p>프로그램마다 필요한 메모리 크기가 다르다. 어떤 애는 몇 메가만 있어도 충분하지만 컴파일러를 컴파일하거나 커다란 데이터를 분석하는 등의 작업에는 수십, 수백, 수 테라의 메모리가 필요할 때도 있다. 하지만 물리적인 메모리(RAM)의 용량은 한계가 있다. 그리고 운영체제는 수많은 프로그램들을 동시에 실행해야 한다. 아무리 메모리를 잘 쪼개서 프로그램마다 잘 할당한다고 해도, 어떤 프로그램이 얼만큼의 메모리가 필요한지를 항상 알아낼 수는 없기 때문에 (정지 문제), 이는 꽤 골치아프다.</p>

<p>모든 컴퓨터 과학 문제는 새로운 수준의 추상화를 추가해서 해결할 수 있다<sup id="fnref:1"><a href="#fn:1" class="footnote" rel="footnote" role="doc-noteref">1</a></sup>. <strong>가상 메모리</strong>는 이러한 문제를 해결하기 위한 추상화로, 모든 프로세스에게 자기만의 독립적인 가상 주소 공간을 제공한다. 이 추상화 덕분에 커널은 진정한 의미의 동시성을 얻을 수 있었고, 더 나아가 메모리 보호(다른 프로세스의 가상 메모리에 접근 불가), 효율적인 메모리 관리, 그리고 스왑 공간을 통해 실제 필요한 메모리가 물리적인 메모리보다 더 큰 프로그램도 실행할 수 있는 등의 추가적인 이득도 얻었다.</p>

<p>현대의 가상 메모리 기법은 소프트웨어와 하드웨어 모두의 도움을 받는다. 소프트웨어 적으로는 가상 주소의 구성이라던지 페이징 기법, 페이지 교체 알고리즘 등이 있고, 하드웨어 적으로는 MMU(Memory Management Unit; 가상 주소를 물리 주소로 변환하는 걸 도와줌)이나 TLB(Translation Lookaside Buffer; MMU를 위한 특수한 캐시) 등이 있다. 1950년대 초창기 컴퓨터에는 현대적인 의미의 동시성이라는 개념은 없었고, 배치 처리(Batch Processing)를 통해서 여러 프로그램을 순차적으로 하나씩 실행할 수는 있었다. 그러다가 고정 파티션과 같은 제한적인 멀티 태스킹이 도입되기 시작했고, 역사에 따르면 여러 다양한 시도가 있었지만 각각 발목을 잡는 문제들, 예를 들면 심각한 단편화(Fragmentation)라던지 성능의 이슈라던지 하는 것들이 있었고, 이를 해결하기 위해서 수많은 연구자와 개발자들이 고군분투한 끝에 1970년대에 들어서는 지금의 우리가 아는 가상 메모리 시스템의 기본이 잡혔다.</p>

<p>아무튼간에 프로그램이 보는 <strong>모든</strong> 메모리 주소는 가상 메모리 주소이다. 좀더 정확히는, MMU의 도움을 받아 64비트 플랫폼 위에서 구현된 페이지 기반 가상 메모리 시스템에서, 메모리 주소는 일반적으로 다음과 같은 인코딩을 갖는다.</p>
<ul>
  <li>페이지 번호 (VPN; Virtual Page Number): 보통 상위 52비트로 표현된다. 이 번호를 가지고 페이지 테이블에서 페이지 테이블 엔트리 (PTE; Page Table Entry)를 찾고 해당 페이지랑 매칭되는 물리 메모리 주소 정보를 알아내어 메모리 연산을 할 수 있다. MMU가 계산해준다.</li>
  <li>페이지 오프셋(Page Offset): 보통 하위 12비트로 표현된다. 하나의 페이지가 관리하는 메모리 덩어리를 정의한다. 즉, 한 페이지 안에서는 이 오프셋 범위 안에서 접근할 수 있다. 오프셋이 표현할 수 있는 크기가 곧 페이지(그리고 매칭되는 프레임)의 크기가 된다. 역사적으로 찾아낸 적당한 휴리스틱에 따라 4KB(12비트)가 디폴트이고 더 큰 크기도 가능하다.</li>
</ul>

<p>예를 들어, malloc으로 얻은 메모리 주소가 0x5555555552a0라면, 이중에서 상위 52비트인 0x555555555는 페이지 번호이고 하위 12비트인 0x2a0은 페이지 오프셋을 나타낸다. MMU는 이 인코딩에 따라 먼저 캐시(TLB)에 이 페이지 번호를 본 적이 있으면 그걸 가져다 쓰고, 없으면 페이지 테이블을 뒤져서 PTE를 찾은 다음 여기 든 정보를 이용해서 실제 물리 메모리의 주소 정보를 계산하여 돌려준다.</p>

<p>좀 더 구체적으로 보면 MMU는 페이지 테이블 엔트리 정보를 이용해서 주소를 변환하고, 메모리 구역의 권한을 검사하고, 페이지 폴트가 발생하면 OS 인터럽트를 발생시키고, TLB 정보를 효율적으로 관리하는 등의 작업을 보조한다. 이런 걸 커널(소프트웨어) 레벨에서 지원해도 되지만 해야 할 일이 명확한 덕분에 마치 FPGA와 같이 특화된 하드웨어의 도움을 받아 훨씬 더 효율적이고 빠르게 관리할 수 있는 것이다.</p>

<p>PTE는 다음과 같은 정보들을 갖고 있다:</p>
<ul>
  <li>페이지 번호와 매칭되는 물리 프레임 번호 (PFN). 앞서 말했듯 페이지 크기와 프레임 크기는 동일하기 때문에, 프레임 번호만 알아내면 같은 페이지 오프셋을 이용해서 물리 메모리 주소에 빠르게 접근할 수 있다.</li>
  <li>해당 물리 메모리에 대한 권한(쓰기, 읽기, 실행)</li>
  <li>유효 (Valid) 비트: 해당 페이지가 물리 메모리에 존재하는지 여부. 이게 0일 때 이 가상 페이지 엔트리에 접근하려고 하면 페이지 폴트가 발생한다. 그러면 커널이 스왑 영역에서 해당 페이지를 찾아서 페이지 정보를 RAM으로 로드하고 PFN을 부여한다.</li>
  <li>Dirty 비트: 해당 페이지가 물리 메모리에 올라간 뒤에, 내용이 수정되었는지 여부. 0이면 변경된 적이 없고 1이면 변경된 적이 있다는 걸 뜻한다.</li>
  <li>모드: 사용자 모드인지 커널 모드인지를 구분한다.</li>
</ul>

<p>페이지 테이블은 보통 커널에 의해서 관리되며 RAM의 한 구역을 차지한다. 근데 앞서 말했듯 64비트 플랫폼에서 페이지 테이블 번호가 52비트인데, 이 크기만큼을 단순하게 다 할당하면 \(2^{52} \times 8B = 32PB\) (여기서 8바이트는 PTE의 크기)라는 엄청나게 큰 메모리가 필요하게 되고 이러면 가상 메모리를 도입한 의미가 없어진다. 그래서 실제로는 52비트를 다 쓰지 않고, 추가로 Multi-Level Page Table이라는 걸 도입해서 필요한 만큼만 로딩해서 쓴다. 현재 x86-64에서는 주로 아래와 같은 4단계의 페이지 테이블을 이용한다.</p>
<ul>
  <li>PML4 (Page Map Level 4): 항상 존재하는 최상위 테이블로 9비트, 즉 512개의 엔트리를 갖는다. 각각의 엔트리 크기는 역시 8 바이트라서 모든 페이지 테이블은 최소 \(512 \times 8 = 2^{11} = 4KB\) 만큼의 용량은 항상 차지하게 된다.</li>
  <li>최상위 PML4 이후로 아래 3개의 하위 페이지들은 필요할 때에만 생성되며 각각 PDP(Page Directory Pointer), PD(Page Directory), PT(Page Table)로 불린다. 모두 9비트로 표현되며 각각 4KB의 크기를 갖는다.</li>
</ul>

<p>이런 식으로 52비트 중 일부인 48비트(9비트 4개 + 12비트)만 쓰고 나머지는 나중을 위해 예약해두었다. 남은 16비트는 최상위 비트의 부호를 확장한다. 48비트만 쓰더라도 표현 가능한 가상 메모리 공간의 크기가 256TB씩이나 되어서 당분간은 걱정이 없다. 더 큰 가상 메모리 공간이 필요하게 되면 페이지 번호에 쓰일 비트를 더 늘리고 페이지 테이블 단계를 하나 더 추가하거나 하면 된다. 실제로 인텔에서는 57비트 주소 체계와 <a href="https://en.wikipedia.org/wiki/Intel_5-level_paging">5단계 페이지 테이블</a>을 통해 최대 128PB 메모리를 표현할 수 있는 주소 체계를 시도해보고 있다.</p>

<p>아무튼 이런 배경으로 현대의 64비트 플랫폼 Unix 기반 OS에서 돌아가는 프로그램은 독립적으로 최대 256TB의 연속적인 가상 메모리 공간을 갖게 되었다. 보통 이걸 반으로 잘라서 128TB는 커널이 쓰고 나머지 절반은 사용자 프로그램이 쓴다. 그래서 예를 들면 <a href="https://www.debian.org/ports/amd64/">데비안</a> 문서를 보면 “64비트 유저랜드에서는 프로세스마다 최대 128TiB의 가상 주소 공간을 갖게 된다”는 설명이 있다.</p>

<p>그런데 이렇게 연속적으로 만든 가상 메모리 공간과 매칭되는 물리 메모리 공간은 당연하지만 크기가 제한적이다. 물리적인 한계도 있지만 커널, BIOS, 펌웨어, 페이지 테이블 등 많은 부수적인 정보들이 RAM에 함께 위치하기 때문이다. 그래서 언젠가는 메모리가 부족한 상황이 올 수 밖에 없다. 이때는 어떻게든 페이지(프레임)를 잠깐 다른 데로 내보내야 하는데, 이렇게 “어떤 페이지를 내보낼지” 결정하는 것을 페이지 교체(Page Replacement) 알고리즘이라고 하며 다음과 같은 것들이 있다:</p>
<ul>
  <li>FIFO: 보통 원형 큐. 구현이 간단하지만, 성능이 항상 좋지는 않음.</li>
  <li>LRU(Least Recently Used): 가장 오랫동안 사용하지 않는 페이지를 교체. 논리적으로 타당한 것 같고, 실제로 많이 사용되는 알고리즘이다.</li>
  <li>LFU(Least Frequently Used): 가장 사용 빈도가 낮은 페이지를 교체. 역시 그럴듯 하다.</li>
  <li>MFU(Most Frequently Used): 가장 사용 빈도가 많은 페이지를 교체. LFU와 정반대로 가장 많이 참조된 페이지는 더 이상 사용되지 않을 거라는 가정이다.</li>
  <li>Clock: LRU의 근사 알고리즘으로 실제 Unix에서 쓰인다고 한다. Second-Chance 라는 알고리즘을 최적화해서 오버헤드를 줄인 버전이다.</li>
</ul>

<p>이 알고리즘들을 왜 “페이지 교체”라고 부를까? 실제로는 물리 메모리가 꽉 차서 발생하고 물리 메모리 관리 단위인 프레임이 교체되는데 말이다. 크게 두 가지 이유가 있을 것이다. 대부분 “논리적으로 메모리가 어떻게 쓰이고 있는지”를 가지고 판단하는데, 이걸 확인하는 방법은 커널이 프레임이 아니라 페이지를 살펴보는 수 밖에 없다. 그리고 페이지는 프레임과 매칭되어 있어서 페이지를 비우면 결국 프레임도 비워진다. 아무튼 간에, 이렇게 메모리가 부족하게 되면 페이지를 스왑 공간으로 방출해서 잠깐 저장해두고, 나중에 필요할 때 다시 로딩하게 된다.</p>

<p>그리고 물리 메모리 공간은 비연속적이다. 동시성을 위해 수많은 프로그램들이 메모리에 같이 올라와 있는데, 가상 메모리와 물리 메모리를 페이지와 프레임이라는 덩어리로 관리하기 때문에 페이지(프레임) 크기인 4KB 만큼 엇갈려서 배치되기 때문이다. “동시성”은 프로그램 조각을 번갈아서 실행하는 (Interleaving) 방식으로 구현되어 왔다. 하나의 프로세스가 CPU를 점유해서 실행되다가 일정 시간이 지나면 다른 프로세스에게 차례를 넘긴다. 근데 결국 나중에 다시 실행되어야 하니까, 일단 하던 작업을 다 정리해서 어딘가에 잠시 옮겨두고, 다음 차례에 실행될 프로세스가 해야 할 작업을 어딘가에서 가져와 메모리에 실어야 한다. 이 작업을 흔히 컨텍스트 스위칭(Context Switching) 이라고 부른다.</p>

<p>페이지 기반 가상 메모리 덕분에 진정한 의미의 동시성이 가능해진 이유 중 하나가 바로 효율적인 컨텍스트 스위칭이다. 페이지 테이블 덕분에 컨텍스트 스위칭을 할 때 전체 메모리를 조작할 필요가 없다. 역사적으로 페이지 테이블 포인터를 CR3 레지스터에 담아왔는데, 컨텍스트를 스위칭할 때 이 레지스터 값만 바꾸면 되므로 매우 효율적이다. 그리고 일관된 캐시 데이터를 위해 TLB도 비워줘야 하는데, 다 비워버리면 그 다음 프로세스가 실행될 때 모든 메모리 작업에 TLB 미스가 나기 때문에 너무 비효율적이다. 그래서 현대의 TLB는 PCID(Process Context ID) 또는 ASID(Address Space ID) 라고 불리는 컬럼을 추가하여 각 캐시 항목마다 “어떤 프로세스가 사용 중인지”도 같이 기록한다. 덕분에 컨텍스트 스위칭 시에 캐시 전체를 비우지 않아도 된다. 만약 이런 페이지 기반의 가상 메모리 시스템이 없다면, 컨텍스트 스위칭에 말 그대로 수 초가 걸릴 수도 있을 것 같다<sup id="fnref:2"><a href="#fn:2" class="footnote" rel="footnote" role="doc-noteref">2</a></sup>.</p>

<p>정리하면, malloc을 호출해서 메모리에 어떤 값을 쓰는 다음 코드는:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">int</span> <span class="o">*</span><span class="n">ptr</span> <span class="o">=</span> <span class="n">malloc</span><span class="p">(</span><span class="k">sizeof</span><span class="p">(</span><span class="kt">int</span><span class="p">));</span>
<span class="o">*</span><span class="n">ptr</span> <span class="o">=</span> <span class="mi">42</span><span class="p">;</span>
</code></pre></div></div>

<p>대충 다음과 같은 단계를 따라간다고 볼 수 있다:</p>
<ol>
  <li>커널이 가상 주소를 생성한다.</li>
  <li>48비트 주소 시스템에 따라 가상 주소를 VPN과 Offset으로 분해한다.</li>
  <li>프로세스 아이디와 VPN을 가지고 TLB를 쿼리해서 최근에 조회된 적이 있는지 살펴본다. 있으면 그걸 가져다 쓴다.</li>
  <li>없으면 TLB 미스가 발생하고 페이지 테이블을 직접 조회한다.
    <ol>
      <li>먼저 CR3 레지스터를 통해서 PML4 주소를 따라간다.</li>
      <li>PML4 -&gt; PDP -&gt; PD -&gt; PTE 순으로 차례대로 접근해서 PTE를 얻는다.</li>
    </ol>
  </li>
  <li>MMU가 이 PTE 항목을 검증하고 물리 주소를 만든다.
    <ol>
      <li>Valid 하면 메모리에 있으니 바로 쓰기 시도를 하고, 아니면 페이지 폴트를 일으켜서 메모리에 적재한다.</li>
      <li>모드와 권한도 확인한다. 쓰기 가능하고 사용자 모드 접근이 가능하면 계속 진행한다.</li>
      <li>유효하다고 판단되면 PTE에 있는 PFN을 이용해 물리 주소를 계산한다.</li>
      <li>여기까지 검증한 최신 내용을 TLB에다가 업데이트한다.</li>
    </ol>
  </li>
  <li>이제 물리 주소를 가지고 물리 메모리에 접근할 수 있다. 여기에 값을 쓴다.</li>
  <li>값을 썼기 때문에 PTE가 업데이트 된다. Dirty 비트를 수정한다. TLB도 업데이트 된다.</li>
</ol>

<hr />

<p>여기까지 가상 메모리에 대해서 아는 내용을 정리하며 마구잡이로 적어 보았다. 이제는 이걸 바탕으로 생기는 다양한 엔지니어링 프랙티스에 대해서 좀더 얘기를 해볼까 한다.</p>

<h2 id="페이지-크기-튜닝">페이지 크기 튜닝</h2>
<p>워크로드에 따라 페이지 크기를 바꿀 수 있다.</p>

<p>페이지 크기가 커지면 페이지가 한번에 커버하는 메모리 영역이 커지므로, 전체 페이지 테이블 크기는 줄어들고 대용량 데이터를 처리하는데 유리해지면서 TLB 미스가 덜나지만, 내부 단편화가 증가하고 페이지 폴트 시에 비용이 증가하게 된다. 반면 페이지 크기가 작아지면 더 작은 단위로 (Find-Grained) 메모리를 할당하므로 내부 단편화가 줄어들어 메모리 낭비를 덜하게 되고 더 세밀하게 메모리를 관리할 수 있지만, 하나의 페이지가 커버하는 영역이 작아져서 페이지 테이블 크기가 말 그대로 폭증하고 이로 인해 TLB 미스가 증가하고 디스크 I/O에 비효율이 발생한다. 이렇듯 트레이드 오프가 있긴 하지만, 현대적인 컴퓨팅 환경에서는 주로 페이지 크기를 늘리는 방향만을 고려한다.</p>

<p>그럼 얼마나 늘릴 수 있을까? 일단 리눅스에는 <a href="https://docs.redhat.com/en/documentation/red_hat_enterprise_linux/8/html/monitoring_and_managing_system_status_and_performance/configuring-huge-pages_monitoring-and-managing-system-status-and-performance">Huge Pages</a>라는 기능을 지원하는데, 보통 2MB(=21비트 오프셋) 또는 1GB(=30비트 오프셋) 두 가지 크기를 지원한다. <code class="language-plaintext highlighter-rouge">/sys/kernel/mm/hugepages/</code> 디렉토리에 <code class="language-plaintext highlighter-rouge">hugepages-2048kB</code> 과 <code class="language-plaintext highlighter-rouge">hugepages-1048576kB</code> 디렉토리가 있는데 여기에 각각 2MB와 1GB의 페이지 크기와 관련된 설정을 담고있다. 이 설정 파일을 직접 쓰거나 아니면 <code class="language-plaintext highlighter-rouge">sysctl</code> 커맨드를 이용해서 Huge Pages 관련 설정을 정적으로 설정할 수도도 있고 (HugeTLB), 아니면 THP(Transparent HugePages)라는 기능을 통해 커널이 동적으로 관리하도록 할 수도 있다.</p>

<p>HugeTLB와 THB는 정적/동적인 것 외에도 몇 가지 차이점들이 있다.</p>

<table>
  <tbody>
    <tr>
      <td> </td>
      <td><strong>HugeTLB</strong></td>
      <td><strong>THP</strong></td>
    </tr>
    <tr>
      <td>할당 방식</td>
      <td>부팅시 또는 런타임에 미리 예약 (Static)</td>
      <td>필요할 때 자동으로 할당 (Dynamic)</td>
    </tr>
    <tr>
      <td>누가 켜나</td>
      <td>서버 관리자, 개발자</td>
      <td>커널이 알아서 관리함</td>
    </tr>
    <tr>
      <td>메모리 유연성</td>
      <td><a href="https://www.percona.com/blog/why-linux-hugepages-are-super-important-for-database-servers-a-case-with-postgresql/#:~:text=HugePages%20never,predictable%20performance.">절대로 스왑되지 않음</a>. 다른 용도로 사용 불가</td>
      <td>필요없으면 자동으로 일반 페이지로 전환 가능</td>
    </tr>
    <tr>
      <td>어플리케이션 수정 필요한지?</td>
      <td>ㅇㅇ. <code class="language-plaintext highlighter-rouge">hugetlbfs</code> 마운트 및 명시적인 사용이 필요함.</td>
      <td>없음, 그냥 켜기만 하면 됨.</td>
    </tr>
    <tr>
      <td>할당 보장</td>
      <td>미리 예약되어 있어서 항상 사용 가능</td>
      <td>메모리 상황에 따라서 실패할 수 있음</td>
    </tr>
    <tr>
      <td>장점</td>
      <td>일관되고 예측 가능, 메모리 압축 오버헤드 없음</td>
      <td>켜기만 하면 되고 관리할 필요가 없음, 메모리를 유연하게 쓸 수 있음</td>
    </tr>
    <tr>
      <td>단점</td>
      <td>메모리가 고정되어서 유연하지 않음, 메모리가 갑작스럽게 가득 찰 수 있음, 소스 코드 수정해야 됨</td>
      <td>메모리 압축 오버헤드가 있고, 예측 불가능한 지연이 발생할 수 있음.</td>
    </tr>
  </tbody>
</table>

<p>그래서 언제 뭘 써야할까? 선조들의 지혜는 다음과 같다.</p>
<ul>
  <li>HugeTLB:
    <ul>
      <li>주로 데이터베이스 서버</li>
      <li>예측 가능성(Predictability)이 가장 중요한 경우, 즉 HFT(High Frequency Trading) 어플리케이션 등</li>
      <li>메모리 사용량이 크면서 일정한 경우</li>
    </ul>
  </li>
  <li>THP
    <ul>
      <li>일반적인 서버 워크로드</li>
      <li>관리가 편한게 좋거나 어플리케이션 코드 수정을 할 수 없는 경우</li>
    </ul>
  </li>
</ul>

<p>정말 특수한 워크로드에서 고성능이 필요한 경우가 아니라면, 보통은 그냥 기본 설정을 가져다 쓰기만 해도 충분하다. 만약 성능을 극한까지 쥐어짜내야 하는 경우가 오면 한번 쯤 이 설정을 들여다보면 되겠다.</p>

<h2 id="cow-copy-on-write">COW (Copy-on-Write)</h2>
<p>다음 프로그램을 보자.</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">int</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
    <span class="kt">int</span><span class="o">*</span> <span class="n">data</span> <span class="o">=</span> <span class="n">malloc</span><span class="p">(</span><span class="mi">1000000000</span> <span class="o">*</span> <span class="k">sizeof</span><span class="p">(</span><span class="kt">int</span><span class="p">));</span>
    <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">i</span><span class="o">=</span><span class="mi">0</span><span class="p">;</span> <span class="n">i</span><span class="o">&lt;</span><span class="mi">1000000000</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">)</span> <span class="n">data</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">=</span> <span class="n">i</span><span class="o">*</span><span class="n">i</span><span class="p">;</span>

    <span class="n">pid_t</span> <span class="n">pid</span> <span class="o">=</span> <span class="n">fork</span><span class="p">();</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">pid</span> <span class="o">==</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span> <span class="c1">// child</span>
        <span class="n">printf</span><span class="p">(</span><span class="s">"%d</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">data</span><span class="p">[</span><span class="mi">0</span><span class="p">]);</span>
    <span class="p">}</span> <span class="k">else</span> <span class="p">{</span> <span class="c1">// parent</span>
        <span class="n">printf</span><span class="p">(</span><span class="s">"%d</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">data</span><span class="p">[</span><span class="mi">0</span><span class="p">]);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>큰 데이터 덩어리를 할당한 다음 프로세스를 <code class="language-plaintext highlighter-rouge">fork</code> 떠서 자식과 부모 프로세스로 분화되었다. 가상 메모리 시스템에서는 프로세스마다 가상 메모리 공간이 분리되어 있어서 서로의 메모리에 접근 불가능하므로, 얼핏 생각하기에 4GB(10억개의 정수) 메모리를 점유한 프로세스가 두 개 생겨야 하니까 총 8GB의 메모리를 쓰게 될 것 같다. 하지만 실제로는 4GB만 쓴다! 왜냐하면 두 프로세스가 모두 데이터를 <strong>읽기만</strong> 하기 때문이다. 실제로 메모리 공간이 분리되는 시점은 <strong>쓰기</strong>가 발생할 때인데, 그래서 이런 최적화를 COW(Copy-on-Write)라고 부르며, 데이터를 쓰는 시점에 복사가 이루어진다는 뜻이다.</p>

<p>그래서 예를 들어 다음 코드에서는 예상한 대로 메모리를 8GB 쓰게 된다.</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">int</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
    <span class="kt">int</span><span class="o">*</span> <span class="n">data</span> <span class="o">=</span> <span class="n">malloc</span><span class="p">(</span><span class="mi">1000000000</span> <span class="o">*</span> <span class="k">sizeof</span><span class="p">(</span><span class="kt">int</span><span class="p">));</span>
    <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">i</span><span class="o">=</span><span class="mi">0</span><span class="p">;</span> <span class="n">i</span><span class="o">&lt;</span><span class="mi">1000000000</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">)</span> <span class="n">data</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">=</span> <span class="n">i</span><span class="o">*</span><span class="n">i</span><span class="p">;</span>

    <span class="n">pid_t</span> <span class="n">pid</span> <span class="o">=</span> <span class="n">fork</span><span class="p">();</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">pid</span> <span class="o">==</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span> <span class="c1">// child</span>
        <span class="n">data</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="o">=</span> <span class="mi">99</span><span class="p">;</span>  <span class="c1">// write!</span>
    <span class="p">}</span> <span class="k">else</span> <span class="p">{</span> <span class="c1">// parent</span>
        <span class="n">printf</span><span class="p">(</span><span class="s">"%d</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">data</span><span class="p">[</span><span class="mi">0</span><span class="p">]);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>이때는 대략 다음과 같은 과정을 거친다.</p>
<ol>
  <li><code class="language-plaintext highlighter-rouge">malloc()</code> 을 통해서 가상 메모리를 할당할 때, 이 구역의 권한은 Read-Write이다.</li>
  <li><code class="language-plaintext highlighter-rouge">fork()</code>가 호출되는 순간, 모든 메모리의 권한이 Read-Only로 바뀌고, 부모와 자식 프로세스의 가상 메모리 페이지들은 서로 같은 물리 메모리 주소를 가리키게 된다.</li>
  <li>자식이 읽기만 하는 것은 그래서 전혀 문제가 없다. Read-Only 권한이 있으니까.</li>
  <li>그런데 자식이 데이터를 쓰려고 하는 순간, MMU는 Write 권한이 없다는 사실을 발견하고 페이지 폴트를 발생시킨다.</li>
  <li>커널이 새로운 페이지와 프레임을 할당하고, 데이터를 복사한 다음, 쓰기를 시도한 프로세스의 페이지 테이블 권한을 Read-Write로 업데이트 하고 쓰기 작업을 완료한다.</li>
</ol>

<p>한 마디로 정말 쓰기 작업이 필요할 때까지 최대한 불필요한 복사를 미루는 것이다. 그래서 멀티 프로세스로 공유된 데이터를 읽기만 하면 아주 효율적이다. 하지만 어떤 프로그래밍 언어에서는 <a href="https://docs.python.org/3/c-api/refcounting.html">데이터를 읽기만 하는 작업이 없는 경우도 있어서</a> 주의를 요한다.</p>

<h2 id="캐시-친화적인-프로그래밍">캐시 친화적인 프로그래밍</h2>
<p>TLB는 하드웨어 캐시라서 히트한다면 1 싸이클 정도로 엄청나게 빠르다. CPU 캐시와 마찬가지로, 메모리에 저장된 것이 데이터일 수도 있고 코드일 수도 있어서, 이를 위해서 iTLB(for Instruction)과 dTLB(for Data)로 나뉘기도 하고, L1, L2와 같은 레벨이 추가되기도 한다. 그리고 TLB 미스가 발생하면 RAM에 접근해야 하는 오버헤드가 발생하는데, 이를 미스 패널티라고 한다.</p>

<p>TLB 히트율은 통상적으로 95-99% 라고 한다. 이게 얼마나 중요한 성능 개선인지 한번 알아보자. 미스 패널티를 대략 100 싸이클이라고 하자. TLB 히트율을 98%라고 하면 평균적인 메모리 접근 시간을 다음과 같이 계산해볼 수 있다.</p>
<ul>
  <li>TLB Hit: 100 (RAM) + 1 = 101 싸이클</li>
  <li>TLB Miss: 1 (TLB 확인) + 100 (페이지 테이블 확인) + 100 (RAM) = 201 싸이클</li>
  <li>평균: 101 * 0.98 + 201 * 0.02 = 103 싸이클</li>
  <li>TLB가 없는 경우: 100 (페이지 테이블) + 100 (RAM) = 200</li>
</ul>

<p>그러니까 98%의 히트율을 갖는 TLB가 있으면 메모리 접근 성능이 거의 두배가 좋아진다. 10, 20% 이런 수준이 아니라 엄청난 성능 개선이다.</p>

<p>L1, L2 캐시 히트는 더 드라마틱하다. 얘는 맵핑 방식이 좀더 복잡하긴 한데, 아무튼 중요한 것은 데이터가 캐시에 있고 없고는 엄청나게 큰 성능 차이를 보인다는 점이다. L1 캐시 속도를 4 싸이클, 메모리 접근을 100 싸이클이라고 하자. L1 캐시 히트율은 통상적으로 95-98% 라고 하는데, 역시 히트율 98%를 가정하면:</p>
<ul>
  <li>L1 Hit: 4</li>
  <li>L1 Miss: 4 (L1 확인) + 100 (RAM) = 104</li>
  <li>평균: 4 * 0.98 + 104 * 0.02 = 6</li>
  <li>L1 캐시가 없는 경우: 100</li>
</ul>

<p>98%의 히트율을 갖는 L1 캐시가 있으면, 성능이 약 17배 좋아진다. 심지어 이건 L1의 속도를 느리게(4), 메모리 속도를 빠르게(100) 가정한 것인데도 그렇다. 두 메모리의 속도가 더 차이나면 성능 갭은 더 벌어질 것이다.</p>

<p>그러니까 우리가 아는 <a href="https://en.wikipedia.org/wiki/Locality_of_reference">지역성</a>, 즉 최근에 쓰인 데이터(메모리) 영역은 금방 다시 쓰이며, 그 데이터와 인접한 다른 데이터들도 함께 쓰일 확률이 높다는 관찰은 정말 중요한 것이다. 덕분에 메모리 접근 속도를 빠르게 할 수 있는 다양한 최적화를 고려해볼 수 있는데, 종종 시간 복잡도를 압도하는 수준으로 성능이 개선되기도 한다.</p>

<p>이런 최적화 방법에는 여러가지가 있다.</p>

<h3 id="tlb-지역성-활용">TLB 지역성 활용</h3>
<p>같은 페이지 내에서 오프셋을 통해서 데이터를 연속적으로 접근하게 되면, 서로 다른 페이지 간의 데이터를 접근하는 것보다 TLB 히트가 많아져서 최적화를 할 수 있다. 가장 많이 알려진 경우는 바로 2차원 배열을 행 우선으로 접근할지 열 우선으로 접근할지 일 것이다.</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// TLB Miss 너무 많음 - 열 우선 접근</span>
<span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">col</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">col</span> <span class="o">&lt;</span> <span class="n">SIZE</span><span class="p">;</span> <span class="n">col</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">row</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">row</span> <span class="o">&lt;</span> <span class="n">SIZE</span><span class="p">;</span> <span class="n">row</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">matrix</span><span class="p">[</span><span class="n">row</span><span class="p">][</span><span class="n">col</span><span class="p">];</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="c1">// TLB Hit 높음 - 행 우선 접근</span>
<span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">row</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">row</span> <span class="o">&lt;</span> <span class="n">SIZE</span><span class="p">;</span> <span class="n">row</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">col</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">col</span> <span class="o">&lt;</span> <span class="n">SIZE</span><span class="p">;</span> <span class="n">col</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">matrix</span><span class="p">[</span><span class="n">row</span><span class="p">][</span><span class="n">col</span><span class="p">];</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>연속적인 메모리 구역에 접근할 때 어떤 식으로 접근하는지 한번 쯤 고민해보면 좋다.</p>

<h3 id="페이지-폴트를-줄이도록-힌트주기">페이지 폴트를 줄이도록 힌트주기</h3>
<p>어떤 메모리가 계속해서 쓰일 거라는 사실을 알면, <a href="https://madvise.org/"><code class="language-plaintext highlighter-rouge">madvise()</code></a> 시스템 콜을 이용해서 힌트를 줄 수 있다.</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">void</span><span class="o">*</span> <span class="n">ptr</span> <span class="o">=</span> <span class="n">malloc</span><span class="p">(</span><span class="n">SIZE</span><span class="p">);</span>
<span class="n">madvise</span><span class="p">(</span><span class="n">ptr</span><span class="p">,</span> <span class="n">SIZE</span><span class="p">,</span> <span class="n">MADV_WILLNEED</span><span class="p">);</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">MADV_WILLNEED</code> 플래그는 <code class="language-plaintext highlighter-rouge">ptr</code> 가상 주소 공간에 임시로 더 높은 우선순위를 부여한다. 그래서 이미 메모리에 있으면 되도록 방출되지 않게 해준다. 이미 메모리에 있는 페이지들은 즉시 프로세스에 맵핑시켜서 페이지 폴트 과정을 거치는 오버헤드를 줄인다.</p>

<h3 id="메모리를-페이지-크기에-알맞게-할당하기">메모리를 페이지 크기에 알맞게 할당하기</h3>
<p>예를 들어 디폴트 페이지 크기인 4KB를 사용하는 플랫폼에서 애매하게 4100 바이트를 할당한다거나 해서 두 페이지에 걸치도록 메모리를 할당하기 보다는, 차라리 데이터 구조를 좀 다이어트해서 4KB 안에 들어오도록 하는 것이 좋다. 그러면 가상 메모리 뿐만 아니라 물리적인 메모리 공간에서도 연속적인 순차 접근이 가능해지고 TLB 히트도 누릴 수 있다.</p>

<p>그리고 페이지 크기와 비슷한 만큼의 메모리가 필요하면 <a href="https://man7.org/linux/man-pages/man3/posix_memalign.3.html"><code class="language-plaintext highlighter-rouge">posix_memalign()</code></a> 시스템 콜을 고려해보는 것도 하나의 방법이다. 예를 들어 다음과 같이</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">char</span> <span class="o">*</span><span class="n">ptr</span> <span class="o">=</span> <span class="n">malloc</span><span class="p">(</span><span class="mi">4090</span><span class="p">);</span>
</code></pre></div></div>

<p>요렇게 메모리를 할당한 경우, 얼핏 생각하기엔 4KB보다 작은 4090 바이트를 할당했으니 한 페이지 안에 들어갈 것 같다. 그럴 수도 있고 아닐 수도 있다. <code class="language-plaintext highlighter-rouge">malloc</code>의 문제점은 <strong>할당된 메모리가 어디서 시작하는지를 모른다</strong>는 점이다. 즉, 4KB보다 작은 4090 바이트의 메모리가 한 페이지 안에 할당될지, 아니면 애매하게 두 페이지에 걸쳐서 할당되는지 알 수 없다.</p>

<p>이럴 때는 다음과 같이 할 수 있다:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">char</span> <span class="o">*</span><span class="n">ptr</span><span class="p">;</span>
<span class="n">posix_memalign</span><span class="p">((</span><span class="kt">void</span><span class="o">**</span><span class="p">)</span><span class="o">&amp;</span><span class="n">ptr</span><span class="p">,</span> <span class="mi">4096</span><span class="p">,</span> <span class="mi">4090</span><span class="p">);</span>
</code></pre></div></div>

<p>페이지 경계인 4KB에 맞춰서 4090 바이트 크기의 메모리를 할당해달라는 의미이다. 이러면 정확하게 하나의 페이지 경계에 맞춰서 메모리가 할당되어서 페이지 폴트가 1번만 발생한다.</p>

<h3 id="메모리-프리페치">메모리 프리페치</h3>
<p>데이터 구조는 메모리 레이아웃을 기준으로 크게 두 종류로 나눌 수 있다.</p>
<ul>
  <li>연속적인 데이터 구조: 배열과 배열 기반의 모든 데이터 구조들, 예를 들어 배열 기반 스택, 큐, 해시 테이블 등.</li>
  <li>비연속적인 데이터 구조: 링크드 리스트, 트리, 그래프, 등</li>
</ul>

<p>이때까지 살펴본 캐시 친화적인 프로그래밍은 주로 배열 기반 데이터를 위한 것이다. 얘네들은 메모리를 덩어리 째로 가져오면 지역성 효과를 제대로 볼 수 있어서 캐시 히트를 마음 껏 누릴 수 있다. 반면에 비연속적이라고 분류된 데이터 구조들은 대부분 “다음 원소”를 알아 내려면 특정 메모리 영역을 뒤져야 한다. 그래서 메모리를 덩어리째 가져와도 그 근처에는 다음 원소가 없을 수 있고, 마찬가지로 (특히 전체를 훑을 때) 방금 쓰인 주소 값은 다시 쓰일 일이 잘 없다. 한마디로 지역성이 깨진다.</p>

<p>그러면 비연속적인 메모리 레이아웃을 가진 데이터 구조는 어떻게 캐시 친화적인 프로그래밍을 할 수 있을까?</p>

<p>모든 경우에 가능한 것은 아니지만, 이 경우에도 방법은 있다. Arm 컴파일러에는 <a href="https://developer.arm.com/documentation/101458/2010/Coding-best-practice/Prefetching-with---builtin-prefetch"><code class="language-plaintext highlighter-rouge">__builtin_prefetch</code></a> 라는 명령어가 있는데, 이걸 통해서 데이터를 미리 페칭하여 캐시 미스 레이턴시를 줄일 수 있다. 다음 접근할 원소의 순서가 정해져 있고, 하나의 원소에 대해서 해야할 작업이 크다면, 활용을 고민해볼 수 있다. 물론 이걸 아무 생각없이 써버리면 캐시에 의미 없는 데이터만 꽉 차버려서 안하느니만 못하게 되니 전략적인 접근이 필요하다.</p>

<p>이와 관련해서 그래프 탐색 시에 메모리 프리페치를 이용해서 엄청난 성능 개선을 얻은 사례가 있는데, 이는 다음 기회에.</p>

<h2 id="time-커맨드가-알려주는-것들">time 커맨드가 알려주는 것들</h2>
<p>프로그램을 프로파일링하는 방법 중 하나로 <code class="language-plaintext highlighter-rouge">\time -v &lt;command&gt;</code> 명령어가 있다. 예를 들어 <code class="language-plaintext highlighter-rouge">\time -v ls</code> 를 실행하여 <code class="language-plaintext highlighter-rouge">ls</code> 커맨드를 프로파일링 하면 다음과 같은 결과가 나온다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>	Command being timed: "ls"
	User time (seconds): 0.00
	System time (seconds): 0.00
	Percent of CPU this job got: 100%
	Elapsed (wall clock) time (h:mm:ss or m:ss): 0:00.00
	Average shared text size (kbytes): 0
	Average unshared data size (kbytes): 0
	Average stack size (kbytes): 0
	Average total size (kbytes): 0
	Maximum resident set size (kbytes): 2560
	Average resident set size (kbytes): 0
	Major (requiring I/O) page faults: 0
	Minor (reclaiming a frame) page faults: 115
	Voluntary context switches: 2
	Involuntary context switches: 1
	Swaps: 0
	File system inputs: 0
	File system outputs: 0
	Socket messages sent: 0
	Socket messages received: 0
	Signals delivered: 0
	Page size (bytes): 4096
	Exit status: 0
</code></pre></div></div>

<p>이제 우리는 여기 나오는 자세한 내용 중에서 특히 메모리 사용량과 관련된 항목에 대해서 조금 더 잘 이해할 수 있다. 예를 들면…</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">Page size (bytes): 4096</code>. 페이지 크기가 4KB 라는 뜻이다.</li>
  <li><code class="language-plaintext highlighter-rouge">Maximum resident set size (kbytes): 2560</code>. 프로그램이 실행되는 동안 실제로 물리 메모리에 로딩된 페이지들의 최대 크기이다. 즉, 페이지 폴트가 발생해서 페이지 테이블에 적재된 메모리의 크기이다. 2560KB, 즉 최대 640개의 페이지가 필요했다.</li>
  <li><code class="language-plaintext highlighter-rouge">Minor (reclaiming a frame) page faults: 115</code>. 마이너 페이지 폴트가 115번 발생했다. 앞에서 얘기했던 페이지 폴트가 이 마이너 페이지 폴트이다. 요청한 페이지가 RAM에 없어서 디스크에서 로드한 횟수이다.</li>
  <li><code class="language-plaintext highlighter-rouge">Major (requiring I/O) page faults: 0</code>. 메이저 페이지 폴트는 발생하지 않았다. 메이저 페이지 폴트는 스왑 공간(디스크)에서 페이지를 복구하는 경우이다.</li>
</ul>

<h2 id="메모리-성능-모니터링하기">메모리 성능 모니터링하기</h2>
<p>높은 가용성을 목표로 어떤 서버를 머신 위에다 운영한다고 하자. 여러가지 메트릭을 모니터링 해야 하겠지만, 그 중 하나는 메모리일 것이다. 서버가 메모리 누수 없이 잘 동작하는지, 혹은 갑작스런 트래픽 증가로 인해서 메모리 로드가 급증했는지를 모니터링 하고 싶다. 그냥 물리 RAM의 사용량을 지속적으로 모니터링하면 될 것 같다.</p>

<p>그런데 실제로는 <strong>스왑 공간</strong>도 중요한 모니터링 대상이다.</p>

<p>가상 메모리 시스템에서 물리적인 메모리가 부족하면 이 프레임들을 잠깐 디스크의 스왑 공간에 저장해뒀다가 나중에 다시 불러온다고 했다. 일시적으로 물리적인 메모리가 부족해져서 스왑 공간을 사용하는 것 자체는 큰 문제가 없다. 우리가 모니터링해야 하는 것은 바로 <strong>스왑 공간의 사용량 증가 추이</strong>이다.</p>

<p>일단 리눅스 커널은 RAM을 최대한 활용하기 위해서 다양한 휴리스틱을 도입하고 있는데 그 중 하나의 파라미터로 Swappiness라는 것이 있다. 이건 커널이 물리 메모리(프레임)을 스왑 공간으로 얼마나 적극적으로 옮길 것인지를 조절하는데,</p>
<ul>
  <li>0-100 사이의 비율로 표현가능하며,</li>
  <li>기본 값은 60정도 이고,</li>
  <li>값이 높을수록 스왑을 더 적극적으로 활용한다.</li>
</ul>

<p>그래서 리눅스 커널은 이 파라미터에 따라서 지금 물리적인 RAM이 충분히 남아있는 데도 스왑 공간을 활용한다. 예를 들어, 디폴트 값인 60인 경우, 전체 메모리 중 40% 만 사용 중일 때에도 스왑 공간을 활용한다. 이 값이 10이라면 램이 90%이상 사용 중이어야 스왑 공간을 쓴다.</p>

<p>이로 인해서, 스왑 공간의 사용량 추이를 모니터링하는 것은 물리 메모리 사용량을 모니터링하는 것과는 또 다른 의미를 갖는다.</p>
<ul>
  <li>스왑 사용량이 적음: 시스템이 메모리를 효율적으로 관리하고 있다. 괜찮다.</li>
  <li>스왑 사용량이 증가하고 있음: 실제로 워크로드가 증가하면서 메모리 사용량이 늘어나고 있다는 뜻이다. 워크로드가 풀리고 나면 평소 사용량으로 돌아야와 한다.</li>
  <li>스왑 사용량이 급증함: 특히 RAM 사용량이 안늘어나고 있다면 더 큰 문제다. 어떤 이유에서든, 물리 메모리 사용량이 부족해서 스왑 공간을 적극 사용하고 있다는 뜻인데, swapiness 파라미터로 Threshold를 조절할 수 있지언정 이런 현상이 발생했다면 실제로 용량이 부족하다는 뜻이다. 주로 프로그램에 메모리 누수가 있거나, 갑작스럽게 워크로드가 증가했거나, 너무 많은 데몬 프로세스가 메모리를 점유하고 있거나, 데이터베이스 사용량이 급증했거나 하는 등의 이유가 있다.</li>
</ul>

<p>특히나 앞에서 말했듯이 스왑은 메모리가 아니라 디스크를 가져다 쓰고, 디스크 접근 속도는 RAM보다 수백, 수천, 수만배는 느리기 때문에, 이건 심각한 성능 이슈를 초래하게 된다. 그러므로 스왑도 항상 모니터링하면서, 워크로드의 추이에 맞춰 프로그램이 잘 처리하고 있는지도 함께 확인해야 한다.</p>

<h2 id="주소는-항상-짝수">주소는 항상 짝수</h2>
<p>커널이 돌려주는 모든 메모리 주소는 가상 메모리 주소라는 것을 이제 알 것이다. 그리고 이 주소는 VPN과 Offset으로 구성되어 있다. 하지만 이것 외에도 추가적인 요구사항이 있는데 바로 정렬(Alignment)이다. 정렬을 알려면 일단 워드(Word)의 개념을 먼저 알아야 한다.</p>

<p>모든 프로세서는 데이터를 자연스럽게 처리하는 기본 단위가 있는데 이걸 워드(Word)라고 한다. 32비트 플랫폼에서는 4바이트(=32비트), 64비트에서는 8바이트(=64비트)가 기본이다<sup id="fnref:3"><a href="#fn:3" class="footnote" rel="footnote" role="doc-noteref">3</a></sup>.</p>

<p>C 표준에서는 이를 따라, <code class="language-plaintext highlighter-rouge">malloc</code>이 리턴하는 메모리는 모든 표준 데이터 타입에 대해서 적절하게 정렬된 메모리를 돌려주도록 강제한다. 그래서 8바이트 (32비트 플랫폼) 또는 16바이트 (64비트 플랫폼) 경계로 정렬된 메모리를 돌려준다. 이렇게 플랫폼의 기본 워드의 두배 크기로 정렬하는 이유에는 여러가지가 있다.</p>
<ul>
  <li>대부분의 프로세서가 SIMD(Single Instruction, Multiple Data) 기능을 제공하는데, 64비트 플랫폼에서 SSE(Streaming SIMD Extensions) 명령어가 16바이트(128비트) 정렬을 요구한다. 즉, 하드웨어의 모든 기능을 이용하려면 정해진 메모리 정렬을 따라야 한다.</li>
  <li><a href="https://learn.microsoft.com/en-us/cpp/build/x64-calling-convention?view=msvc-170">x64 함수 호출 규약</a>에 따르면, 함수 호출 시에 스택이 16바이트로 정렬되어야 한다고 명시되어 있다. 그래서 <code class="language-plaintext highlighter-rouge">malloc</code>이 16바이트 정렬을 보장하면, 할당된 메모리를 스택처럼 사용하는 코드들도 안전해진다.</li>
  <li>모든 프리미티브 데이터 타입의 크기를 아우를 수 있는 최소 크기가 128비트이다.</li>
</ul>

<p>결론적으로 <code class="language-plaintext highlighter-rouge">malloc</code>이 돌려주는 가상 메모리 주소는 항상 정렬 요구사항을 만족한 값이다. 다르게 말하면 <strong>모든 메모리 주소는 짝수,</strong> 정확히는 8의 배수 (32비트 플랫폼) 또는 16의 배수 (64비트 플랫폼) 라는 말과 같다. 이 말은 곧 모든 메모리 주소의 최하위 비트는 항상 0이라는 뜻이다. <a href="https://dev.realworldocaml.org/runtime-memory-layout.html#scrollNav-1-1:~:text=a%20pointer%20if%20the%20lowest%20bit%20of%20the%20block%20word%20is%20zero.">어떤 언어에서는 이 성질을 이용해서 메모리 주소와 정수를 효율적으로 구분하기도 한다</a>. 사실 이 글을 쓰게 된 동기 중 하나가 “진짜 메모리 주소는 항상 0으로 끝나는건가?” 라는 사소한 궁금증이었다.</p>

<hr />

<p>모던 컴퓨팅 환경의 근간이 되는 페이지 기반 가상 메모리는 이렇듯 하드웨어와 소프트웨어의 협력을 통해서 구현되는 엄청난 시스템이었다. 다음에는 이거보다 한 단계 위의 추상화 레벨, 즉 어플리케이션 레벨에서 메모리를 관리하는 방법에 대해서 써봐야지.</p>

<hr />

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1">
      <p>“We can solve any problem by introducing an extra level of indirection.” 이른바 <a href="https://en.wikipedia.org/wiki/Fundamental_theorem_of_software_engineering">FTSE (Fundamental Theorem of Software Engineering)</a> 이라고 불리는 내가 좋아하는 문구다. <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:2">
      <p>예전에 게임 업계에 있을 때, 플레이어들이 “랙”을 인지하는 게 보통 레이턴시가 100ms를 넘어갈 즈음부터 이고 200ms를 넘으면 확실하게 랙임을 인지한다는 얘기를 들었었는데, 그런 의미에서 수 초의 컨텍스트 스위칭은 동시성이라고 부를 수 없는 수준이 아닐까. <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:3">
      <p>인텔은 1 워드를 16비트(2바이트)로 규정하고 여기에 배수로 Double Word (DWORD; 32비트)와 Quad Word (QWORD; 64비트)를 정의해놨다. 이건 원래 x86 아키텍쳐가 16비트 프로세서로 시작했기 때문에 생긴 하위 호환성 같은 것이라고 한다. <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>sangwoo-joh</name></author><category term="cs" /><category term="essay" /><summary type="html"><![CDATA[목차]]></summary></entry><entry><title type="html">AI 시대 단상</title><link href="https://blog.untyped.kr/musings-on-the-ai-era" rel="alternate" type="text/html" title="AI 시대 단상" /><published>2025-10-10T00:00:00+00:00</published><updated>2025-10-10T00:00:00+00:00</updated><id>https://blog.untyped.kr/musings-on-the-ai-era</id><content type="html" xml:base="https://blog.untyped.kr/musings-on-the-ai-era"><![CDATA[<p>AI 시대가 무엇인지 정확하게 정의하긴 어렵겠지만, 요즘을 AI 시대라고 부르는 데 큰 이견은 없을 것 같다. 적어도 AI 시대의 시작 지점 부근에는 있는 것 같다. 많은 것들이 변하고 있고, 그로 인해서 많은 것에 적응해야 할 것 같다.</p>

<p>얼마전에 썼던 <a href="../my-llm-usage">나의 LLM 사용기</a>에서 나는 “통합적인 환경을 구축하는데 일가견이 있는 구글이 스카이넷이 될 지도 모른다”라는 나이브한 생각을 남긴 적이 있다. 지금도 큰 틀에서 비슷하지만 몇 가지 달라진 생각이 있어서 단상을 남겨보려 한다.</p>

<p>구글에는 생각할 수 있는 거의 모든 서비스가 다 있다. 검색 엔진부터 이메일, 공유 드라이브, 사진 공유, 클라우드 서비스, 유튜브, 캘린더, 미팅 앱, 지도 등, 이제는 이 중 일부를 떼어놓고 일상 생활을 할 수 없을 정도로 영향력이 커졌다. 덕분에 구글은 돈을 많이 벌고 있다. 그런데 우리가 사용하는 대부분의 구글 서비스들은, 일단은 무료인 게 많다. 특히 가장 핵심 서비스라고 생각되는 검색 엔진이 무료이다. 지메일도 일정 용량까지는 무료이고 웬만해서는 이 용량이 꽉 찰 일은 잘 없다. 캘린더도 무료이고. 나머지 드라이브나 유튜브처럼 부분적으로 유료인 서비스들도 있지만 기본적으로는 다 무료로 쓸 수 있다.</p>

<p><strong>근데 이거 정말 무료일까?</strong></p>

<p>아주 오래전부터 미디어에 대한 비평 중에 다음과 같은 것들이 있다.</p>

<blockquote>
  <p>Television Delivers People.</p>
</blockquote>

<p>위의 말은 무려 1973년에 예술가 Richard Serra랑 Carlota Fay Schoolman이 한 말이다. 티비가 사람을 배달한다니. “티비를 산 건 소비자이지만, 티비가 소비자를 광고주에게 배달한다”는 의미로 아래에 나올 말들과는 조금 맥락이 다르긴 하지만, 미디어 시대에서 “소비”에 대해서 다른 관점을 제시하지 않았을까 싶다.</p>

<blockquote>
  <p>If you are not paying for it, you’re no the customer; you’re the product being sold.</p>
</blockquote>

<p>위의 말은 2010년에 Tim O’Reilly 라는 인플루언서가 트위터에서 인용해서 유명해진 말이다. 70년대에는 티비였지만, 이제는 더 확장되어 모든 서비스 제품에도 적용 되었다.</p>

<blockquote>
  <p>Once it’s free, you’re not the customer anymore, you’re the product.</p>
</blockquote>

<p>그리고 내가 이런 류의 비평을 처음 들었던 것은 2011년 만우절에 Seth Godin이 쓴 글 중 일부인 위 문장이었다. 그때부터 이 말이 뇌리에 박혔다. 뭐가 공짜면, 나는 더 이상 소비자가 아니라 그 제품이다. <sup id="fnref:1"><a href="#fn:1" class="footnote" rel="footnote" role="doc-noteref">1</a></sup> 그러니까 꽤 오래 전부터 꾸준히 비슷한 경고를 해왔던 것이다.</p>

<p>서론이 길었는데, 아무튼 나는 제법 얼마 전부터 구글의 검색 엔진에서 이 문장을 실감하기 시작했다. 뭘 검색하면 최상단에 정말 교묘하게 내가 원하는 결과가 아닌, 구글이 광고하는 항목이 종종 나타나기 시작했다. 처음엔 하나였던 것 같은데, 특정 키워드에서는 이게 세 개까지 늘어나기도 했다. 아주 작은 글씨로 “이거 광고에요”라는 문구가 있긴 했지만, 주의하지 않으면 알기 어렵겠다 싶었다. 모든 검색에서 이런 광고가 최상단에 뜨는 것은 아니었기 때문에 초반엔 그냥저냥 괜찮았다. 보통은 별 뜻 없이 검색 결과의 최상단을 클릭해서 살펴보기 때문에, 원치 않는 광고 사이트에 들어가게 되면 조금 귀찮긴 했다. 그러다가 문득 저 문장이 생각이 났다. 그리고 깨달았다. 구글 검색에서는 더이상 내가 소비자가 아닐수도 있겠구나. 분명 예전에는, 네이버나 다음과 같은 포털 사이트 검색은 온갖 광고와 배너가 덕지덕지 붙어있는데다가 검색 품질도 좋지 못해서 쓸 수 없는 물건이었고, 반면에 구글은 광고 하나 없이 깔끔한 텍스트 링크 위주로 내가 원했던 결과만 보여줘서 오히려 썰렁한 느낌이 들 정도인 시절이 있었는데… 영원한 건 절대 없다는 지드래곤 선생님의 말씀이 스쳐지나갔다.</p>

<p>그런데 문제는 요즘 LLM을 잘 쓰려면 검색이 거의 필수라는 사실이다. RAG니 컨텍스트 엔지니어링이니 하는 것들이 있는데, 핵심은, 일단 LLM의 학습 비용은 꽤 비싼데, 학습 이후에 나온 새로운 사실들(텍스트)을 이용하려면 검색을 해야만 한다는 것이다. 그리고 LLM은 텍스트 모델이라 이런 새로운 사실들을 기반으로 제법 괜찮은 품질의 답변을 낸다. 그런데 만약 검색 결과에 검색 엔진이 프로모션하는 조금은 주제를 벗어난 결과가 컨텍스트에 섞여버리면, 그만한 낭비가 없다. 물론 이제 모델을 잘 쓰는 여러 기법들이 연구/발견되고 있는 터라, Reasoning 단계를 거쳐 (1) 일단 검색 결과의 항목들을 추린 다음, (2) 그 중에서 관련이 있을 것 같은 제목의 글들을 우선적으로 보는 것도 충분히 좋은 방법이겠다. 하지만 구글 검색 엔진이 갑자기 <a href="https://aisparkup.com/posts/5428">검색 결과 개수를 제한</a>해버릴지도 모른다.</p>

<p>게다가 지금은 글로벌 LLM 시대라서 꼭 제미나이를 고집할 필요는 없다. 원조 국밥 ChatGPT, 코딩 특화 Claude Sonnet, 돈 많은 아저씨의 저력을 보여주는 Grok, 유럽산 자존심 Mistral, 분발하길 바라는 Llama 등 비슷한 품질의 다양한 모델들이, 더 저렴해지고 있고, 챗 서비스 뿐만 아니라 API까지 쓸 수 있게 되었다. 이래서 경쟁이란 좋은 것이다.</p>

<p>아무튼 이러한 이유로 나는 적어도 검색에 한해서는 구글을 벗어나는 방법을 진지하게 고민해보기 시작했다. 그리고 예전부터 써보려고 시도했지만 실패했었던 <a href="https://kagi.com">Kagi Search</a>에 다시 발을 들여놓아 보았다. 검색 엔진에 돈을 내야한다니? 하지만 앞에서 말했듯, 무료인 서비스는 내가 그 제품이 되는 것이리라. 그러느니 차라리 정당한 비용을 지불하고 원하는 서비스만 이용하는 것에 설득당할 수 밖에 없었다. 일단 검색에 광고가 <strong>전혀</strong> 붙지 않는다는 점은 생각보다 좋았다. 프라이버시를 중시하여 개인정보 트래킹도 하지 않기 때문에<sup id="fnref:2"><a href="#fn:2" class="footnote" rel="footnote" role="doc-noteref">2</a></sup> 사이트 자체의 로딩도 엄청나게 빨라서 쾌적했다. 자체 알고리즘을 통해서 제공한다는 검색의 우선순위는 (광고 없는) 구글의 그것과 크게 다르지 않아서 마이그레이션하는데 큰 어려움은 없었다. 그리고 무엇보다 AI 시대에 발맞춰, 높은 플랜을 구독하면, 앞에서 말했던 대부분의 최신 모델들을 <strong>Kagi 검색 엔진과 함께</strong> 이용할 수 있어서, 고품질의 맥락을 넣는데 효과적이라는 생각이 들었다. 문득 “모든 AI 서비스 회사는 어느 정도 검색에 대한 통제를 가져야 하지 않을까?” 라는 생각이 들었다. 핵심은 정말 중요한 데이터를 잘 모아서 관리하고, 그것들을 필요할 때 효과적으로 가져오는 것이다. Kagi는 구글을 뛰어 넘을 검색 엔진을 개발하고 있었고, 마침 AI 시대가 도래하여 LLM 가격이 충분히 싸졌고, 마침 LLM은 고품질의 맥락을 필요로 하기 때문에 텍스트를 엔지니어링 하던 경험이 큰 도움이 되어 여기까지 제품이 발전해왔다는 생각이 든다.</p>

<p>나는 이제 비용을 지불하고 검색을 하는데 큰 저항이 들지 않는다. 오히려 그동안 검색은 공짜여야한다고 생각해왔던 게 이상하다는 생각마저 들었다. 계속 사용할지는 미지수지만 아직까지는 만족스럽다. 과연 어떻게 될지 두고봐야지.</p>

<hr />

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1">
      <p>사실 이렇게까지 길어질 말은 아니었고 그냥 마지막 문장인 “공짜면 니가 제품이다”는 말을 하고 싶었던 거였는데, 원전을 계속 찾아가다 보니 재밌어서 그냥 다 적어봤다. <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:2">
      <p>왜냐하면, 소비자가 이미 비용을 지불했기 때문에 그럴 필요가 없다는 논리이다. 옳은 방향이라는 생각이 든다. <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>sangwoo-joh</name></author><category term="cs" /><category term="musing" /><summary type="html"><![CDATA[AI 시대가 무엇인지 정확하게 정의하긴 어렵겠지만, 요즘을 AI 시대라고 부르는 데 큰 이견은 없을 것 같다. 적어도 AI 시대의 시작 지점 부근에는 있는 것 같다. 많은 것들이 변하고 있고, 그로 인해서 많은 것에 적응해야 할 것 같다.]]></summary></entry></feed>