요즘시대에 어셈블리가 필요한가?

이 질문에 대한 답으로 제가 했던 일, 하는 일을 소개하겠습니다.

커널 패닉 디버깅

https://codeascraft.com/2012/03/30/kernel-debugging-101/

위 링크의 포스팅만 보셔도 커널 디버깅이 어떻게 진행되는지 아실 수 있습니다. 그런데 이 문서를 보고 이해하실 정도면 제 강좌를 안보셔도 되고 안보시고 다른 일을 하셔야 시간일 낭비하지 않는 길이겠지요. 이 문서가 이해가 안되시는 분들이 이 강좌를 보셔야 됩니다. 그리고 이 강좌를 보신 후에 그 다음에는 이 문서가 이해가 되시기를 바랍니다.

위 포스팅의 내용이 뭐냐면 커널이 죽더라는 이야기입니다. 그런데 커널이 죽으면서 뭐를 하다가 문제가 생겼다는걸 알려주는데

BUG: unable to handle kernel NULL pointer dereference at 0000000000000060
IP: [<ffffffff8142bb40>] __netif_receive_skb+0x60/0x6e0
PGD 10e0fe067 PUD 10e0b0067 PMD 0
Oops: 0000 [#1] SMP
last sysfs file: /sys/devices/virtual/block/dm-6/removable
CPU 12
Modules linked in: veth bridge stp llc e1000e serio_raw
                   i2c_i801 i2c_core sg iTCO_wdt
                   iTCO_vendor_support ioatdma dca i7core_edac
                   edac_core shpchp ext3 jbd mbcache sd_mod
                   crc_t10dif ahci dm_mirror dm_region_hash
                   dm_log dm_mod [last unloaded: scsi_wait_scan]
Pid: 0, comm: swapper Not tainted <kernel> #1 Supermicro X8DTT-H/X8DTT-H
RIP: 0010:[<ffffffff8142bb40>]  [<ffffffff8142bb40>] __netif_receive_skb+0x60/0x6e0
RSP: 0018:ffff88034ac83dc0  EFLAGS: 00010246
RAX: 0000000000000060 RBX: ffff8805353896c0 RCX: 0000000000000000
RDX: ffff88053e8c3380 RSI: 0000000000000286 RDI: ffff8805353896c0
RBP: ffff88034ac83e10 R08: 00000000000000c3 R09: 0000000000000000
R10: 0000000000000001 R11: 0000000000000000 R12: 0000000000000000
R13: 0000000000000015 R14: ffff88034ac93770 R15: ffff88034ac93784
FS:  0000000000000000(0000) GS:ffff88034ac80000(0000) knlGS:0000000000000000
CS:  0010 DS: 0018 ES: 0018 CR0: 000000008005003b
CR2: 0000000000000060 CR3: 000000010e130000 CR4: 00000000000006e0
DR0: 0000000000000000 DR1: 0000000000000000 DR2: 0000000000000000
DR3: 0000000000000000 DR6: 00000000ffff0ff0 DR7: 0000000000000400
Process swapper (pid: 0, threadinfo ffff880637d18000,
task ffff880337eb9580)
Stack:
 ffffc90013e37000 ffff880334bdc868 ffff88034ac83df0 0000000000000000
<0> ffff880334bdc868 ffff88034ac93788 ffff88034ac93700 0000000000000015
<0> ffff88034ac93770 ffff88034ac93784 ffff88034ac83e60 ffffffff8142c25a
Call Trace:
 <IRQ>
 [<ffffffff8142c25a>] process_backlog+0x9a/0x100
 [<ffffffff814308d3>] net_rx_action+0x103/0x2f0
 [<ffffffff81072001>] __do_softirq+0xc1/0x1d0
 [<ffffffff810d94a0>] ? handle_IRQ_event+0x60/0x170
 [<ffffffff8100c24c>] call_softirq+0x1c/0x30
 [<ffffffff8100de85>] do_softirq+0x65/0xa0
 [<ffffffff81071de5>] irq_exit+0x85/0x90
 [<ffffffff814f4dc5>] do_IRQ+0x75/0xf0
 [<ffffffff8100ba53>] ret_from_intr+0x0/0x11
 <EOI>
 [<ffffffff812c4b0e>] ? intel_idle+0xde/0x170
 [<ffffffff812c4af1>] ? intel_idle+0xc1/0x170
 [<ffffffff813fa027>] cpuidle_idle_call+0xa7/0x140
 [<ffffffff81009e06>] cpu_idle+0xb6/0x110
 [<ffffffff814e5ffc>] start_secondary+0x202/0x245
Code: 00 44 8b 1d cb be 79 00 45 85 db 0f 85 61 06 00 00 f6 83 b9
      00 00 00 10 0f 85 5d 04 00 00 4c 8b 63 20 4c 89 65 c8 49 8d
      44 24 60 <49> 39 44 24 60 74 44 4d 8b ac 24 00 04 00 00 4d
      85 ed 74 37 49
RIP  [<ffffffff8142bb40>] __netif_receive_skb+0x60/0x6e0
 RSP <ffff88034ac83dc0>
CR2: 0000000000000060

이렇게 어셈블리를 모르면 제대로 이해할 수 없는 형태로 정보를 줍니다.

대강 보면 ip 레지스터 값이 ffffffff8142bb40일때, C 코드로 말하면 __netif_receive_skb 함수 중간 0x60지점에서 죽었다는 것입니다. 그리고 __netif_receive_skb 함수가 어떤 과정으로 호출되었는지를 알려주고, 스택에 어떤 값들이 있는지, 죽을 때 어떤 레지스터에 어떤 값이 있었는지를 알려줍니다.

어떤 레지스터가 어떻게 사용되고, 함수 호출을 위해 스택에 어떤 값이 들어가야하는지, 함수의 시작 지점에서 0x60을 더한 주소에 어떤 명령어가 있었는지 등등 디버깅을 하려면 어셈블리를 알아야합니다.

__netif_receive_skb 함수 몇번째 줄에서 어떤 변수의 값이 잘못된건지를 알려주면 좋겠지만, 아직 그정도까지는 개발이 안되었으니 어쩔 수 없습니다. 누군가 그정도까지되는 디버거를 개발해주시기를 바래야지요.

커널 패닉 메세지를 보고 디버깅하는 것은 이 강좌의 후반부에 실전 예제로 제대로 다시 설명하겠습니다. 그 전에는 디버깅을 하기 위한 배경지식이 먼저겠지요.

64비트 ARM 벤치마크 성능 측정

예전에 했던 일중에 하나입니다. 같은 ARM프로세서에 32비트로 동작할때와 64비트로 동작할 때의 성능을 비교하기위해 어떤 벤치마크 툴을 돌렸습니다. 벤치마크 항목이 여러개있는데 대부분 당연히 비등비등하게 나왔는데 어떤 항목에서 64비트 성능이 이상하게 낮았습니다.

우선 질문 하나. 왜 32비트 64비트의 성능이 비슷해야할까요? 컴퓨터 구조를 모르면 이상하게 64비트로 동작하는게 빨라보입니다. 마치 큰 차가 작은 차보다 빠른 것처럼 보이는거랑 같습니다. 속도가 빠른게 빠른거지 크고 작고가 성능에 영향을 주는게 아닙니다. 64비트라는건 프로세서의 레지스터와 버스가 64비트로 동작한다는 의미입니다. 32비트는 32비트 레지스터라는 것이구요. 메모리에서 데이터를 읽을 때는 128비트등 메모리 규약에 따라 버스폭이 정해집니다. 캐시에서 데이터를 읽을 때는 프로세서 내부 버스 크기에 따라 읽구요. 따라서 캐시에서 데이터를 읽을 때 32비트든 64비트든 읽어오는 속도는 버스 클럭에 따라 같습니다.

물론 한 클럭에 64비트를 읽으면 32비트보다 더 큰 값을 읽을 수 있겠지요. 그런데 컴퓨터 세상에서 얼마나 많은 데이터가 32비트보다 더 클까요? 사실 엄청 정밀하거나 대규모 연산이 아닌 이상 32비트 데이터가 대부분입니다. 즉 레지스터가 64비트로 늘어났다고해서 데이터 연산이 두배로 빨리지지 않는다는 것입니다. MMX등 특수하게 64비트 레지스터를 통채로 활용하는 경우도 있습니다. 그런 연산을 쓰면 32비트 레지스터를 쓸때보다 속도가 2배 가까이 빨라질 수 있습니다. 특히 암호 관련된 라이브러리들은 64비트로 빌드된게 더 빠릅니다. 하지만 범용 연산에서는 속도 차가 없습니다. 칩 벤더들이 새로운 칩을 출시하면서 64비트 칩이며 성능이 무려 수십% 높아졌다고 광고하는데요. 사실은 새로운 칩 자체가 빨라진거지 64비트가 되서 빨라진게 아닙니다. 그 새로운 칩에 32비트 프로그램을 돌려보고 64비트를 돌려보면 성능은 비슷합니다. 레지스터나 버스등 컴퓨터의 구조를 모르면 이해가 안될 수밖에 없지요.

그럼 다음 질문. 왜 벤치마킹 점수가 다 비슷한게 하나만 유독 떨어질까요? 저는 해답을 얻기 위해 벤치마킹 프로그램을 디스어셈블했습니다. 소스가 있어서 할 수 있었습니다. 그리고 발견한게 32비트와 64비트 코드의 차이가 있었는데, 64비트 코드에서는 데이터 연산의 중간 결과를 저장하기위해서 스택을 사용한게 아니라 MMX 레지스터를 사용하는 것이었습니다. 컴파일러가 메모리보다 레지스터에 데이터를 읽고 쓰는게 더 빠르니까 그렇게 기계 코드를 만든 것입니다.

그런데 뭐가 문제일까요? 사실은 레지스터에 따라 접근 속도가 다릅니다. (ARM 아키텍처의 경우) ax,bx같은 범용 레지스터들은 접근 속도가 같은데 MMX나 부동소수점 연산에 사용되는 레지스터들은 CPU 코어 내부에 있는게 아니라 부동소수점 연산 유닛에 들어가있어서 결국 메모리에 데이터를 저장하는 것보다 더 느려졌던 것입니다. 단순히 접근 속도만 놓고보면 부동소수점 연산 유닛이 더 빠릅니다. 하지만 부동소수점 연산 레지스터에 접근하는 기계어는 CPU의 파이프라인이 멈추도록 만들고, 메모리에 접근하는 기계어는 메모리에 접근하는 동안 다른 연산이 파이프라인에서 처리될 수 있으므로 속도 차이가 나게됩니다. 결국은 컴파일러 커뮤니티에 문의를 했고, 이미 알려진 문제여서 다음 버전에서는 고쳐질 거라는 답을 얻었었습니다. ARM의 64비트 코어가 출시된지 얼마안된 때여서 생겼던 문제였습니다.

어셈블리를 모르고 CPU구조를 모르는 사람은 이 문제를 파악할 수 있었을까요?

strcpy 인라인 어셈블리

http://lxr.free-electrons.com/source/arch/x86/lib/memcpy_64.S

커널에서 쓰는 memcpy() 함수가 여기 있습니다. 어셈블리로 만들어져있습니다. 왜일까요? 주석을 보면 알 수 있습니다.

/*
 * memcpy_erms() - enhanced fast string memcpy. This is faster and
 * simpler than memcpy. Use memcpy_erms when possible.
*/

다른거 없습니다. 빠르면 끝입니다. memcpy, strcpy, strcmp 등등 순전히 메모리에 접근해야하는 함수들이 느리면 프로그램의 전체적인 성능에 얼마나 치명적인지 최적화를 해본 사람만 압니다. 요즘 멀티코어에 3기가넘는 프로세스가 있는데 뭐가 걱정이냐하는 분들도 계십니다. 이런 최적화가 밑바탕에 있어서 우리가쓰는 커널이나 libc 라이브러리가 그만한 성능을 가지고 있고, 그래서 최적화 고민없이 개발할 수 있다는걸 모르기 때문입니다. 하드웨어의 성능을 최대한 끌어내고, 하드웨어의 최신 기능을 최대한 활용하는 방법은 어셈블리로 구현하는 것입니다.

궁금하지 않으세요? 세계 최고의 프로그래머들이 만든 리눅스 커널에서 이 함수가 빠르다라고 주석에 써있는 함수는 얼마나 빠른걸까 궁금하지 않으신가요?

컴파일러란 뭘까?

누가 어셈블리로 개발하나 다 컴파일러가 해주는데.

컴파일러는 언어를 기계어 명령의 리스트로 변환하는 예술의 한 분야입니다. 인간의 과학이 만들어낸 예술중 하나이지요. 예술의 경지에 이른 과학이라는데 완벽히 동의합니다만, 다시 말하면 인간이 만든 것뿐이라는 것입니다.

컴파일러가 최적화를 잘해줍니다. 우리 대부분이 아무리 고민해봐야 컴파일러 최적화 옵션 올리는것만 못할때가 많습니다. 그런데 컴파일러도 인간이 만든 것이라 대자연처럼 인간이 손대지 않아도 잘 흘러가는게 아닙니다. 컴파일러를 잘 동작시키고 최적화가 잘되게 하려면 뭔가 조건이 필요합니다.

http://lxr.free-electrons.com/source/lib/string.c#L713

여기 memcpy()의 C 언어 버전이 있습니다. 완벽하게 동작하는 memcpy() 함수입니다. 이 함수를 아무리 최적화해도 memcpy_erms()를 따라기지 못합니다. 왜 그럴까요?

컴파일러는 고급언어로 개발된 코드 중에 어떤 조건에 맞는 코드 패턴이 나타나면 최적화하게 됩니다. 즉 그 패턴에 맞게 프로그래밍을 해야 최적화를 할 수 있다는 것입니다. 그 패턴이란게 무엇일까요? 제가 쓴 강좌 "태초의 프로그래밍 언어 어셈블리"를 보신 분은 아실 것입니다. C라는 언어가 어떻게 생겨난 언어인지를요. 어셈블리로 개발하던 사람들이 어셈블리보다 약간 더 쉬우면서 어셈블리처럼 좋은 성능을 내기위해 만든 언어가 C입니다. 많은 부분이 어셈블리와 유사합니다. 그리고 그 당시의 많은 개발자들은 어셈블리를 아는 상태였기 때문에 C로 개발하면서도 이 코드가 컴파일러를 통해 어떤 코드로 변환될지를 예측하면서 개발했었습니다. 컴파일러는 패턴을 찾기가 쉬웠고, 패턴에 맞게 최적화하기 쉬웠습니다. 지금의 많은 개발자들은 어셈블리와 기계에 대해서 모르는 상태에서 언어를 언어로만 배웠습니다. 컴파일러의 최적화 패턴도 결국은 기계가 어떻게하면 더 빠르게 동작하는지를 활용하려는 것이므로, 기계를 모르는 사람은 컴파일러의 패턴을 이해할 수가 없습니다. 결국 컴파일러가 잘 최적화할 수 있는 코드를 만들 수가 없습니다. "패턴 365"같은 천페이지짜리 책이 나와서 달달 외워서 최적화가 잘되는 코드를 만들 수도 있겠지요. 어느게 더 쉬울까요?

완전 추상화를 지향하는 함수형 언어나 LISP같이 어떤 고급언어들은 약간 다른 패러다임을 가지고 있습니다. 분명 그런 언어들의 개발에는 기계에 대한 지식이 없어도 됩니다. 그런데 그런 언어의 컴파일러는 누가 만들까요? 그런 언어의 라이브러리는 누가 만들까요? 그런 언어라고 해도 디버깅할 때 동기화 문제, 데드락 문제, 타이밍 문제가 생기면 어떻게 해결할 수 있을까요? 고급 언어로 개발하면서 그런 미묘한 문제를 만나신 적이 없다면, 솔직히 말씀드리면 정말 제대로된 소프트웨어를 개발한 적이 없으신 겁니다. 아니면 QA를 제대로안해서 문제를 발견하지 못하신 것일 수도 있습니다. 많은 국산 소프트웨어가 출시는 되는데 막상 써보면 이런저런 문제가 많아서 제대로 쓸 수가 없습니다. QA를 안하거나 문제가 발견되도 해결을 안하는 것입니다. 개발자의 문제가 아닌 경우도 있겠지만, 개발자의 문제도 있습니다. 능력이 되고 금방 해결할 능력이 있다면 회사 차원에서도 해결안하고 그냥 출시할 이유가 없습니다. 시간이 많다면 개발자를 기다려주겠지만, 시간은 전세계 어느 회사나 없습니다. 빨리 해결할 수 있는 개발자가 있나 없나 차이일 수 있습니다.

당연한 소리겠지만 좋은 알고리즘, 자료구조를 쓰는게 최고이며 첫번째로 할 최적화입니다. 그 다음에 할 일이 컴파일러에 맞는 최적화이고, 그 다음이 하드웨어에 맞는 최적화입니다. 내가 만든 코드가 컴파일러에게 어떻게 읽혀지고, 컴파일러가 어떻게 판단하게될지를 알면서 코딩해야만 컴파일러 최적화의 도움을 받을 수 있습니다. 컴파일러의 목적은 최적화된 기계 코드를 만드는 것입니다. 따라서 기계를 모르는 사람은 컴파일러가 이해하기 쉬운 코드를 만들기 힘듭니다.

Write Great Code 책에 대한 평가: 한국과 아마존비교

위의 디버깅, 속도 측정, 컴파일러, 인라인 어셈블리 예를 보시면 어쩌면 이런 생각이 드실 겁니다. 이런걸 우리가 손댈 필요가 있나? 손댈 기회는 있나? 우린 그냥 쓰면 되는거 아닌가?

그런데 분명히 누군가는 이런 작업을 합니다. 또 이런 작업을 해서 어플 개발자들에게 제공하고, 어플 개발자들은 편하게 사용합니다. 누굴까요? 그리고 그런 사람들은 돈을 얼마를 벌까요? 그냥 까놓고 말해서 이런 작업을 하는 사람들을 우리는 고급 개발자라고 부릅니다. 리눅스 커널에서 미묘한 동기화 문제나 데드락 문제를 어셈블리 레벨에서 디버깅하는 개발자들, 어플 개발자들에게 좀더 빠른 성능의 라이브러리를 제공하는 사람들, 좀더 최적화를 잘해주는 컴파일러를 만드는 사람들을 고급 개발자라고 부르지 뭐라고 부르겠습니까.

그럼 이런 생각도 듭니다. 그런 사람들을 한국에서 만나본적이 있으신가요? 이렇게도 생각할 수 있습니다. 한국에 고급 개발자가 없는 이유가 수요가 없어서일까요 공급이 없어서일까요? 수요가 있으면 공급이 생길까요 공급이 있으면 수요가 생길까요? 분명한건 많은 고급 개발자들이 이미 해외로 떠나버렸다는 것입니다.

한국 책 사이트들과 아마존에서 Write Great Code에 대한 평가를 보세요. 리뷰 숫자를 보세요. 이런 분야에 대한 관심도를 비교할 수 있습니다. 요즘 시대에 누가 어셈블리를 하냐고요? 누가 하고 있을까요?

results matching ""

    No results matching ""