개념글 모음

Disclaimer


이 글을 읽으면서 한번에 이해가 쉽지 않아도 좋다. 웬만한 컴퓨터 관련 학과 3-4학년쯤에 배울 내용을 커버하고 있기 때문이다.

프로그래밍 언어(프로그래밍 언어의 설계 등을 배우는 과목이다), 컴파일러, 컴퓨터 구조, 알고리즘 및 계산이론에 대한 지식들을 망라하고 있기 때문에 관련 과목의 사전 지식이 있으면 한결 이해하기 쉽다.


한줄 요약


intrinsic은 어떤 소스 없이도 컴파일러와 아키텍쳐가 자체적으로 정의하는 함수이다. 다른 모든 함수들은 이 위에서 구현된다.


여러분에게 컴퓨터는 무엇인가?


상당히 추상적인 질문이다. 누군가는 이 질문에 대한 답을 '하드웨어와 소프트웨어로 구성되어 일련의 목적을 달성하기 위해 사용하는 전자기기' 라고 할 것이고, 누군가는 '스마트폰, 데스크톱 컴퓨터, 태블릿 컴퓨터, 전자 오락기기, 랩탑 등을 망라하는 일련의 기기 집합' 이라고 정의할 것이다. 모두 훌륭한 정의지만, 컴퓨터라는 학문의 관점에서 컴퓨터를 정의하면 다음 한 단어로 정리된다.


증명기

진짜다. 학술적인 관점에서는 저 단어 하나로 끝이 난다. 쉽게 납득하기 힘들 수가 있는데, 이 채널 사람들이 늘 하고 있는 프로그래밍으로 예시를 들어 보자.

대부분의 프로그래밍 언어에서 가산 연산은 다음과 같이 서술한다.


a + b

이는 우리가 쓰는 대부분의 프로그래밍 언어가 다음과 같이 정의되기 때문이다.


좌변과 우변에 실수 타입의 변수 및 상수로 두는 operator로 표현되며, 해당 값을 실수 가산 연산하여 결과로 갖는다.

컴파일러는 이를 다음과 같이 증명한다.

1. + 연산자를 찾았다. 정의에 따르면 좌, 우변에는 변수나 상수가 와야 하고, 각 타입은 실수여야 한다.

2. 좌변, 우변 모두 상수가 아니다. 따라서 이 둘은 변수로 해석해야 한다.

3. 좌변과 우변의 변수 a, b를 스코프(이것도 나중에 프로그래밍 언어 관련 글에서 다루겠다)에서 가용한 변수로 찾는다. 찾을 수 없거나, 타입이 명시된 것과 다르다면 증명에 실패한다. 따라서 컴파일하지 않는다.

실제 컴파일러가 정확히 이 논리로 동작한다. 자바스크립트에서는 어떻게 동작할까?


C++과 같은 강타입/묵시적 형 변환 비 친화적 언어와 달리, 자바스크립트는 형 변환에 친화적인 언어이다. 자바스크립트는 덧셈에 다음과 같이 정의에 세부사항을 추가하여 변형한다.


좌변과 우변에 실수, 문자열, 배열, 부울 타입의 변수 및 상수를 갖는 operator로 표현되며, 각각의 경우 다음과 같은 결과를 가진다.

1. 양 변이 실수인 경우, 해당 값을 실수 가산 연산하여 결과로 갖는다.

2. 한 변에 실수, 다른 변에 부울 형식이 올 경우, true를 1, false를 0 으로 취급한다.

3. 양 변에 부울 형식이 올 경우, true를 1, false를 0으로 취급한다.

4. 한 변에 문자열, 다른 변에 부울 및 실수 형이 올 경우, 문자열과 다른 변수를 concat 한다.

...

여기서 벌써 문제가 발생한다. C++과 같은 강타입, 변수 형 변환 금지 언어의 경우에는 상대적으로 이 증명과정이 쉬웠다. 하지만 자바스크립트의 경우 스코프 내에서 변수의 타입이 얼마든지 변화할 수 있다. 그렇다면 이걸 어떻게 증명할까?


자바스크립트는 인터프리터 언어기 때문에 이것이 문제가 되지 않는다. 즉, C++과 같은 정적 컴파일 언어의 경우에는 위 증명 과정이 실행 전에 끝나야 하기 때문에 JS와 같은 코드를 작성할 수 없다. 논리적으로는 문제가 없으나, C++의 사용 목적 중 하나가 '메모리를 최대한 예측 가능하게 활용하는 것'으로 사용 하는 것이기 때문에 메모리 사용 크기를 컴파일 시점에 모두 유추하여 실행 시점에 공간 낭비를 최대한 줄여 활용할 수 있어야 하므로, JS와 같이 변수의 공간을 비효율적으로 사용하는 방법으로 언어의 구성 요소를 정의할 수도 있지만 그렇게 하지 않은 것이다.


자바스크립트는 이와 같은 증명을 컴파일 시간이 아니라 실행 시점에 증명을 시도하며, 위에서 정의한 더하기 연산의 규칙에 런타임에서 위배되지 않는다면 그대로 실행해 준다. 그렇다면 다음과 같은 상황은 어떨까?


function concatTest (input) {

  var a = '';

  for (var I = 0; I < input.length; I++) {

    a = a + input[I];

  }

  return a;

}


input은 아마도 배열일 것이다. 그렇다면 위 코드에서 for 문 안을 도는 동안 a 에 input의 구성요소들을 매번 concat하여 저장하므로 a의 타입은 항상 문자열일 것이므로, 위에서 이미 정의한 더하기 연산의 정의에 의해 언제나 a는 문자열일 것이다. for문을 마치면 a의 타입은 문자열이므로, 최종적으로 이 함수는 문자열을 반환할 것이라 예측할 수 있다.


그렇다면 여기서 문제: 만약 input의 형이 실행시점에서 배열이 아니었다면?


이 경우 위의 코드는 실행 시점에서 증명이 불가하다. 위의 코드가 실행 가능하기 위해서는 input이 iterable(반복 가능. 일반적인 언어에서 Map(Dictionary), Tuple, Array(List), Queue, Stack 등을 의미한다) 해야 하기 때문이다(자바스크립트에서 length 프로퍼티는 대개 iterable 타입에서만 정의된다). 따라서 input가 length라는 프로퍼티를 가져야 한다고 전재한 3행에서 증명을 실패한다. 증명에 실패했으므로 이는 곧 예외로 이어진다.


즉, 우리가 프로그래밍 과정에서 늘 만나는 예외는 컴퓨터 공학의 관점에서 다음과 같이 정의할 수 있다:


증명 가능하다고 전제된 코드(== 작성된 코드)와, 실제 동작 시점에서의 전제 사이의 모순


이 결론을 이해하기 힘들 수도 있다. 네트워크 실패는? 입출력 시간 초과는? Hardware Failure은? 논리에는 문제가 없다고 볼 수 있지 않나?

위 3개의 예시를 다음과 같이 바꿔 적어 보겠다


1. 네트워크 실패로 인한 예외: 네트워크를 통해 통신한 결과가 정상적일거라는 전제를 세웠으나 이것이 충족되지 않음. 즉 전제가 잘못되어 모순 발생

2. 입출력 시간 초과: 제한 시간 내로 기대하는 Input이 올 거라는 전제가 충족되지 않음. 즉 전제가 잘못되어 모순 발생

3. Hardware Failure: 하드웨어가 정상 동작할 거라는 전제가 충족되지 않음. 즉 전제가 잘못되어 모순 발생


이와 같이 컴퓨터에서 존제하는 모든 예외상황(오류, 예외, Failure 등으로 불리는)은 '모순 발생' 이라고 정리할 수 있다. 이는 컴퓨터가 '증명기' 이기 때문이다.


아직 납득하기 힘든 점이 있을 수 있다. 컴퓨터가 증명기라고 치자. 그렇다면 화면에 무언가 표시되는 과정은? 소리가 나는 것은? 현금 출납기의 프로세서가 연산을 마친 후 현급 투입사출구에서 현금이 나오는 것은? 로봇의 프로세서가 연산을 마친 후 모터를 움직이는 것은? 이 모든 과정은 논리와는 상관이 없지 않나? 컴퓨터가 증명을 위해 존재하는 것은 아니지 않나?


이 또한 다음과 같이 정리하면 쉽게 이해가 가능하다: '증명 이외 모든 동작은 컴퓨터에게 있어 부작용이다'


즉, 현금 출납기의 현금 처리, 모니터를 통한 화면 출력, 스피커를 통한 음향 출력 등 논리 증명과 무관한 모든 동작은 학술적 관점에서의 컴퓨터에게 있어 단지 부작용일 뿐이다. 다만 그 부작용이 인간에게 굉장히 유용할 뿐이다.


한술 더 떠, 컴퓨터가 켜지고 꺼질 때 까지의 모든 동작도 결국 증명 과정이라고 정리할 수 있다. 펌웨어, 부트로더, 운영체제, 응용 전 과정을 거쳐 사용자에게 유의미한 '부작용' 을 제공하고, 최종적으로 펌웨어에게 정상 종료를 반환하고 증명을 종료하는 것이 학술적 관점에서의 컴퓨터 동작과정이다.


주) 더 관심 있는 사람은 정지 문제를 찾아보길 바란다. 주어진 프로그램의 증명에 걸리는 시간을 일반화하여 예측할 수 있는 방법이 존재하지 않는다는 것을 설명하는 문제이다. 가장 유명한 증명은 앨런 튜링의 증명, 최초의 증명은 앨런조 처치의 증명이다.


증명은 하드웨어까지 이어져야 한다.


소프트웨어 관점에서 지금까지 컴퓨터가 증명기인 이유를 보였다. 하지만 컴퓨터는 소프트웨어와 하드웨어로 구성된다. 소프트웨어에서의 논리 증명이 완벽하다고 한들, 이 증명이 하드웨어에서 실제로 구동되지 않으면 의미가 없다. 즉, 소프트웨어에서의 결론과 하드웨어 사이의 Missing link를 채워주는 것이 필요하다. 이 논리적 공백을 어떻게 채워야 할까?


이 논리적 공백은 바로 컴파일러가 채워 준다. 하드웨어에서의 논리 증명을 맡는 것이 바로 프로세서, 컨트롤러 등의 연산장치이다. 이들은 기억장치 및 연결 신호에서 실행 가능한 바이너라와, operand 들을 입력으로 받아 결과를 산출한다. 이 때 이들이 해독 가능하며 실행 가능한 바이너리로의 전환, 즉 소프트웨어와 하드웨어에서의 실행 사이의 논리 공백을 해소하는 것이 바로 컴파일러인 것이다(인터프리터는 실행 시점의 컴파일러로 이해하면 편하다).


컴파일러는 궁극적으로 다음과 같이 구성된다: 어셈블리로 직접 작성된 코드와 아키텍쳐별로 컴파일러 내에 구현되어 있는 함수


어셈블리로 작성된 코드는 이해하기가 쉽지만 컴파일러 내에 직접 구현된 함수라는 말은 이해하기가 어렵다. 모든 언어별 표준 함수가 컴파일러 내에 구현된 것이 아닌가?


놀랍게도 이 질문에 대한 답은, 아니오이다.


C/C++의 stdio.h 헤더 파일에 정의된 함수의 실제 구현체를 보면, __로 시작하는 타입과 함수들, __asm 및 __asm__ 등으로 가득 차 있는 코드를 볼 수 있다.  이 중 __asm__(GCC 기준, MSVC와 Clang에서는 __asm 확장으로 정의)은 아키텍쳐별 기계어로 직접 작성된 코드로, 컴파일러에 의해 0과 1의 연속으로 1:1 변환된다. 그럼 나머지 함수는 뭘까?


__로 시작하는 함수들이 대개, 컴파일러가 어떤 헤더와 소스 파일 없이도 자체적으로 정의하고 있는 함수, 즉 intrinsic이다. 실제로 이 함수들도 거의 코드와 기계어 간 1:1 변환을 통해 0과 1의 연속으로 변환되는데, 모든 함수를 inline 어셈블리로 정의하는 것은 지나치게 가독성이 떨어지니 1:1 변환 과정을 쉽게 하기 위해서 내부적으로 함수라고 정의를 해 두고, 컴파일러가 나중에 기계어로 1:1 번역을 하는 것이 바로 intrinsic이다. 해당 함수들은 각 아키텍쳐별로 정의되며(x86-IA32및 x64-amd64는 인텔과 AMD가, ARMv7 및 ARMv8은 arm이 정의한다), 이들을 컴파일러들이 표준삼아 구현한다. 물론 컴파일러가 자체적으로 정의하기도 한다.


때문에 같은 컴파일러라 하더라도, 아키텍쳐별로 intrinsic들은 다르게 정의되며, C/C++의 stdio.h도 컴파일러가 지원하는 하드웨어 벤더별로 각각 다른 인라인 어셈블리와 intrinsic들을 사용해 작성한다. 이와 같은 과정을 통해서 비로소 논리적으로 증명 가능한 코드가 하드웨어에서까지 증명 가능해지는 것이다.


그런데 우리가 이들 코드를 쓸 필요는 없지 않나? 어차피 컴파일러가 다 알아서 해 주지 않나?


물론 대개의 경우는 그렇다. 그런데, 게임 개발이나 운영체제, 커널 개발 등 하드웨어와 매우 밀접한 프로그래밍을 해야 할 때, 성능을 위해서 각 프로세서의 기능들을 언어의 추상화를 거치지 않고 직접 가져다 써야 할 때가 있다. 가령 벡터합 등 여러 oeprand의 연산을 한 개 명령어로 처리하는 경우가 대표적이다(인텔의 AVX 명령어셋, ARM의 NEON 명령어셋 등의 SIMD 관련 명령어셋이 대표적이다). 이와 같은 기능을 개발할 경우에는 실제로 intrinsic을 여러분이 직접 쓰게 된다.


주) 벡터 명령어는 병렬 처리의 대표 사례 중 하나지만, 멀티코어 프로그래밍과는 개념이 좀 다르다. 멀티코어 프로그래밍은 프로그램을 여러 코어에 나눠서 처리하는 것이고, 벡터 명령어는 여러 개의 operand들을 한 개 코어가 동시에 처리하는 것이다.


그렇다면 각 아키텍쳐별로 정의된 intrinsic들은 어디서 찾아볼 수 있을까?


가장 유명한 문서 중 하나가 MSDN의 Compiler Intrinsic이다(관련 문서 중 제일 깔끔하다). 각 하드웨어 벤더가 정의한 intrinsic들의 MSVC 집합이 서술되어 있다. 이외에도 GCC(워낙에 문서 정리를 개판으로 해서 굳이 링크를 걸진 않겠다. 구글링하면 어차피 다 나온다), LLVM-Clang (다만 Clang은 모든 언어를 하드웨어 어셈블리하는 로직을 추상화하고 있는 LLVM 백엔드를 쓰고 있어서, LLVM, Clang 등의 문서를 모두 따로 보아야 한다), 인텔(x86 전체를 인텔이 관리하는 만큼 AMD가 자체 명령어를 제외하면 별도로 정리하고 있진 않다), ARM 등이 정리한 문서를 참조하면 필요 정보를 모두 찾을 수 있다.