2024/12/28

탐욕과 갈등의 종착점

 

요즘의 정치 상황을 보면 개탄스럽기 그지 없다. 옳고 그름이 없고 아군과 적군 만이 있다. 우리 편의 이익을 위해선 상대 편을 죽이려고까지 한다. 힘에 의한 저차원의 저질 정치를 고차원의 고상한 정치인 양 포장한다. 우리 편 말은 모두 맞는 것이고 상대 편의 말은 모두 틀린 것이다. 상대 편 잘못은 먼지나게 털어대지만 우리 편 잘못은 모두 덮는다. 입만 열면 법과 원칙을 떠들어 대던 자 들이 중대한 범죄를 저지르고서도 '내로남불'이다. 법과 원칙은 모든 이에게 보편적으로 적용되지 않을 뿐만 아니라, 우리 편을 보호하기 위한 수단이다.

 어쩌면 법과 원칙은 태생적 한계가 있어 보편적으로 적용될 수 없는 것인지도 모른다. 인간들 간의 복잡한 갈등 양상을 법과 원칙이라는 몇 구절로 규정할 수 없기 때문이다. 법을 아는 자들은 자신을 합리화하기 위해 법을 해석하고, 심지어 우리 편의 이익을 위해 악용하기 마련이다. 법이 먼저일까 상식이 먼저일까? 인간 사회를 지탱하는 힘은 법과 원칙이 아니라 상식과 신뢰이다. 법과 원칙은 상식과 신뢰를 유지하기 위한 수단, 즉, 인간 사회를 지탱하기 위한 최소한의 도구일 뿐이다. 우리 사회가 극한의 탐욕과 집단 이기주의로 치달리고 있음을 한탄하지 않을 수 없다. Leadership 또는 권위(Authority)는 돈이나 권력으로 획득할 수 있는 것이 아니다. 상식과 신뢰를 몸소 실천함으로써 대중으로부터 자연스럽게 획득되는 것이다. 지금의 현실은 리더십이 있는 이는 드물고, 머리는 좋을지 몰라도 자신 들의 이익에만 집착하는 놈들의 세상이 되어 버렸다. 그런 놈들을 뽑아 주는 개/돼지들과 살고 있으니, 나도 개/돼지이다. 좀 더 구체적으로 말하자면 사법 고시 출신 기득권 세력 들이 이 나라를 망치고 있다. 권력을 잡지 못했을 뿐 사실 의사 집단도 매 한 가지다. 언론은 이미 돈과 권력의 시녀가 된지 오래다. 물론, 그 중에는 성실하게 자신의 역할을 하는 이들도 있고 이들까지 싸잡아 욕하려는 것은 아니다.

호랑이 담배피던 시절에도 유능한 리더 들은 민심이 천심 임을 잘 알고 있었다. 삼국지 인물 중 유비와 조조가 대표적이다. 천자가 될 수도 있었던 조조지만 민심을 알기에 스스로 천자가 되지 않았다. 권력을 가진 자도 절제해야 하거늘, 지금 우리 사회는 권력을 잡으면 독재자가 되려고 한다. 같은 편이라면 독재자도 문제 될게 없다. 독재자들은 스스로 범죄자임에도 불구하고 자신의 영달을 추구하고 군림하기 위해 법과 원칙을 강조한다. 맘에 안드는 자들은 계엄령에 있는 문구대로 '처단'하면 그만이다. 국회의원들 마저 민심보다 우리 편 이익을 먼저 따지는 세상이 되어 버렸다. 기득권을 가진 자들은 다음 세대에게 좋은 나라를 물려 주겠다는 철학도 없고, 현재 세상에서 나와 우리 편만 잘살면 된다는 생각이 판치고 있다. 박정희 같은 독재자면 괜찮다는 생각을 하는 이들도 있겠지만, 우리 세대가 이미 경험했듯이 '왜 니만 헤쳐 먹냐?'는 논리로 새로운 독재자들을 양산할  뿐이고 민초들은 개/돼지로 살아가야 한다. 힘의 원리가 지배하는 약육강식의 세계이다. 세계 1위의 저 출산율이 거저 얻어진게 아니다. 결국은 국민 전반에 번진 탐욕과 이기주의의 결과물이다. 탐욕과 갈등의 종착점은 승자 없는  내란이나 전쟁일 수 밖에 없고, 그 결과는 아프리카의 일부 국가나 시리아와 같이 국가 소멸에 준하는 제 3 세계로 전락하는 것이다.

이런 현상은 물론 우리나라 만의 문제는 아닐 수도 있다. 트럼프가 미국 대통령이 된 후 전세계적으로 정치적 이기주의 현상이 표출되기 시작한 것도 사실이다. 적어도 트럼프 1기의 탐욕과 이기주의는 저강도의 국가 우선주의와 정권 교체로 봉합되었다. 다가올 미래는 트럼프로 인해 세상이 더욱 혼란스러워질 가능성이 높다. 하지만, 우리는 미국과 같은 강대국도 아니고, 당장 내년부터 대내외적으로 온갖 악재들을 막아내야만 하는 상황이다. 다른 나라 상황이 어떻든 우리는 우리의 길을 묵묵히 가야만 한다.

현실로 들어와서 국무총리 권한 대행까지 탄핵된 지금, 앞으로 가능한 시나리오는 몇개 남지 않았다. 가장 쉽고 빠르게 국내 정치가 안정될 수 있는 지름길은 부총리 권한 대행이 헌법재판관을 임명하면 되는 것이다. 헌법재판소의 결정을 따르고 그 이후의 길을 법이 정한 대로 가면 된다. 물론, 이 길에서도 불확실성이 남아 있다. 헌법재판소의 결정에 따라 새로운 갈래 길이 생긴다. 대통령이 파면 되면 다시 대선을 치르면 그만이다. 하지만 파면되지 않으면 또 다시 혼란이 생길 수 밖에 없다. 대통령은 이미 식물이 되었는데, 파면이 되지 않는다면 복귀한 대통령은 독재자가 될 수 밖에 없다. 저항하는 국민들과 충돌이 발생할 수 밖에 없다. 결국은 대혼란의 길이다.

하지만, 헌법재판소로 통하는 길 마저도 막힐 수 있다. 현실적으로 우리 편 이익에 집착하는 놈들이 권력의 끈을 놓지 않기 위해 시간 끌기로 가는 경우의 길 만이 남는다. 이 길에서는 6인 체제의 헌법재판소 결정이 어떻게 나든 한쪽은 찬성하고 한쪽은 반대하는 결과가 나올 수 밖에 없다. 또 다시 아전인수 식의 법 해석을 할 것이다. 국민들이 적군과 아군으로 나뉠 것이다. 결국은 끝없는 대혼란의 길이다. 어쩌면 '내란의 힘'이 원하는 것은 대혼란 속에서 다시 독재의 길을 가기를 원하는 것인지 모른다.

결론적으로, 현 상황을 안정적으로 타개할 수 있는 유일한 길은 헌법재판소가 9인 체제로 대통령 파면 결정을 하는 것 뿐이다. 자기 편 이익에만 집착하는 이들에겐 국가의 미래와 국민의 고통은 중요하지도 않고 급한 일도 아니다. 이 들은 야당 지도자가 범죄자이기 때문에 자신들이 정당하다고 우긴다. 이 들이 평소 주장하듯이 법과 원칙대로  대선을 치르고 국민의 선택에 맡기면 모든 게 질서있게 해결된다. 자기 편만 옳다고 우기는 탐욕과 갈등의 세상에선 둘 다 망하는 길 만이 남는다. 그 결과는 국가와 국민들이 고스란히 떠안게 될 뿐이다.

아직은 깨어있는 젊은 세대가 많은 것이 그나마 이 나라에 기댈 수 있는 유일한 희망이다. 역사는 반복되고 세상은 언제나 만들어 가는 자들의 것이다. 젊은 세대 들에게 고차원의 정치란 다양한 집단의 목소리를 하나의 목소리로 엮어 낼 수 있는 정치임을 말해 주고 싶다. '타협의 정치'는 거래의 정치가 떠오르고, '협치'는 현실적으로 불가능한 이상적인 정치란 느낌이 든다. 다양한 집단의 요구를 절충하고 조율하는 정치가 되어야 한다. 이게 가능하려면 손해를 보더라도 서로 양보할 줄도 알아야 한다. 좌파와 우파는 뇌 구조도 다르다는데 좌파와 우파는 아군과 적군이 아니라, 가족이나 친족 내에도 있을 수 있고 친구들에게도, 우리의 이웃에도 있을 수 있다. 누가 맞고 틀리고의 문제가 아니고 서로 다름을 인정할 줄 알아야 한다. 진짜 우리의 적은 자신 들의 이익을 추구하기 위해 국민을 편가르기 하는 자들이다. 좀 손해를 보더라도 탐욕과 이기주의를 자제해야만 더 나은 세상을 만들 수 있다. 개/돼지로 살고 싶지 않다면 스스로 거부할 수 있어야 한다.

2023/02/12

Signed Distance Field를 이용한 폰트 렌더링

 

Signed Distance Field(SDF; 부호 거리 장)는 임의의 위치에서 어떤 경계까지의 최단 거리들을, 경계 안은 양수로 경계 밖은 음수로 표현한 것이다(부호를 반대로 표현하기도 한다). 가령, 타원이 경계선이라면 타원 접선 들과의 직교(gradient 방향) 선들의 길이를 SDF로 나타낼 수 있다. 등고선 지도(contour map)가 대표적인 SDF이다. SDF 활용 분야는 많지만 여기서는 font rendering 기법에 대한 것이다. 실제 활용시에는 Signed Distance Function (SDF; 부호 거리 함수)이라는 개념이 유용해 보인다. Signed Distance를 계산하는 함수이다. 함수든 장이든 SDF를 혼용해도 무리가 없다.

OpenGL에서 truetype font(vector font)를 FreeType 라이브러리로 rendering 한 후, 이미지 파일로 font atlas(지도)를 저장했다가 text rendering 시에 사용했던 적이 있다. 그 때 SDF atlas를 쓰면 bitmap atlas와 달리 글자 크기에 상관없이 사용할 수 있다는 글을 보았는데, SDF에 대해 좀 찾아 보다가 복잡해 보여서 그러려니 했었다. 그런데, 아이디어는 어느날 갑자기 문득 떠오른다. 그리고 아이디어를 기록하지 않으면 영원히 잊혀진다.

SDF를 이용한 폰트 렌더링

원래 SDF를 제대로 계산하려면 경계선인 직선이나 곡선들을 Bezier 함수 등으로 근사하여 contour 경계들을 만들어서 각 지점들과의 최단 직교 거리를 계산해야 한다. 폰트 렌더링 시에 흔히 쓰이는 방식은, vector font를 확대한 bitmap으로 렌더링해서 pixel 경계들과의 Euclidean distance를 구한 후 downsampling 해서 SDF 폰트 atlas를 만드는 것이다. 아무튼, 엄밀하게 하자면 SDF 계산 알고리즘 자체도 복잡하고 한글이나 한자는 글자 수가 많아 계산량도 만만치 않다.
내게 떠오른 아이디어는 정수(integer) SDF를 이용해서 폰트를 렌더링하자는 것이다. 간단하지만 특허도 받을 수 있는 아이디어이다. 하지만, 아이디어가 활용되는 것이 더 낫다고 본다. 폰트 렌더링은 물체 모델링과 유사한데, 충돌 감지 같은 다른 분야에서도 SDF 활용시, 상대적인 값이 의미 있고 굳이 정확한 값이 필요하지 않다면, 정수 SDF를 충분히 활용해 볼 수 있을 것이다. 계산 성능을 높이면서도 효과적인 방법이기 때문이다.

폰트 렌더링 시에 pixel 들간의 거리는 정수로 표현될 수 밖에 없으니 단순무식(?) 더하기/빼기(= Manhattan distance)로 정수 값의 SDF를 구해서 사용해도 충분하리라는 것이 기본적인 생각이었다. 아래 그림은 FreeType으로 생성한 32- pixel 'g' 문자(glyph)를 정수 SDF로 표현한 것이다. Vector font이기 때문에 32 x 32 pixel이 아니고 문자 마다 matrix 크기는 달라진다. 어렴풋이 '0'을 경계로 'g'가 보일 것이다. pixel 경계는 SDF 값이 0이고 경계 안은 양수, 밖은 음수로 나타낸 경계까지의 거리이다. 다르게 보면, SDF가 등고선 들이므로, 각각의 지점에서 0은 해수면이 되고 음수는 해수면 아래 높이, 양수는 해수면 위의 높이가 된다. 입체 글자 모형이 물위에 떠 있다고 생각할 수 있다.

 -5 -4 -2 -1 -1  0  0  0  0  0 -1 -1 -1 -1  0  0 -1
-3 -2 -1 0 0 0 -1 -1 -1 0 0 0 0 0 0 0 -1
-2 -1 0 0 -1 -1 -2 -2 -2 -1 0 1 0 -1 -1 -1 -2
-2 -1 0 0 -1 -2 -3 -3 -3 -2 -1 0 0 -1 -2 -2 -3
-1 0 1 0 -1 -2 -3 -4 -3 -2 -1 0 0 -1 -2 -3 -4
-1 0 1 0 -1 -2 -3 -4 -3 -2 -1 0 1 0 -1 -2 -3
-1 0 1 0 -1 -2 -3 -4 -3 -2 -1 0 0 -1 -2 -3 -4
-1 0 1 0 -1 -2 -3 -3 -3 -2 -1 0 0 -1 -2 -3 -4
-2 -1 0 0 -1 -1 -2 -2 -2 -1 0 1 0 -1 -2 -3 -4
-3 -2 -1 0 0 0 -1 -1 -1 0 0 0 -1 -2 -3 -4 -5
-3 -2 -1 0 0 0 0 0 0 0 -1 -1 -2 -3 -4 -5 -6
-2 -1 0 0 -1 -1 -1 -1 -1 -1 -2 -2 -3 -4 -5 -6 -7
-2 -1 0 -1 -2 -2 -2 -2 -2 -2 -3 -3 -3 -3 -4 -5 -6
-1 0 0 -1 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -3 -4 -5
-1 0 1 0 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -2 -3 -4
-2 -1 0 1 0 0 0 0 0 0 0 0 0 0 -1 -2 -3
-3 -2 -1 0 0 0 0 0 0 0 0 0 1 1 0 -1 -2
-2 -1 0 -1 -1 -1 -1 -1 -1 -1 -1 -1 0 1 1 0 -1
-1 0 0 -1 -2 -2 -2 -2 -2 -2 -2 -2 -1 0 1 0 -1
-1 0 0 -1 -2 -3 -3 -3 -3 -3 -3 -3 -2 -1 0 0 -1
0 0 -1 -2 -3 -4 -4 -4 -4 -4 -3 -3 -2 -1 0 0 -1
0 1 0 -1 -2 -3 -3 -3 -3 -3 -2 -2 -1 0 0 -1 -2
-1 0 0 -1 -1 -2 -2 -2 -2 -2 -1 -1 0 0 -1 -2 -3
-2 -1 0 0 0 -1 -1 -1 -1 -1 0 0 0 -1 -2 -3 -4
-3 -2 -1 0 0 0 0 0 0 0 0 -1 -1 -2 -3 -4 -5

그런데, 경계 지점이 곡선 중심이라면 직교 거리는 대각선 방향이고 실제 거리는 2의 제곱근 = (1+1)^0.5 = 1.414씩 증가한다. 즉, 정수로 SDF를 표현하면 분명히 대각선 근처 SDF 값들은 오차가 커진다. 하지만 어차피 Euclidean distance를 사용하더라도 rounding 오차가 발생할 수 밖에 없으므로 내 아이디어를 검증해 볼 만한다. SDF 값이 같은 놈들끼리 contour를 형성하는데, 정수 오차가 발생하더라도 contour 간 거리 비율은 일정하기 때문에 오차가 증폭되지 않을 뿐 아니라, 폰트 렌더링시에도 SDF 값들의 비율에 따라 pixel 강도가 변하기 때문에 SDF 사용 취지에도 잘 부합하게 된다. Euclidean distance를 사용하는 것에 비해 성능을 높일 수 있고 atlas 용량도 줄일 수 있다. 아이디어를 검증하기 위해 Manhattan distance를 정확히 계산하지도 않았는데, 알고리즘의 정확성에 비해 효용성이 별로 없으리라 생각했기 때문이다. 이것이 단순무식 더하기/빼기라고 했던 이유이다. 사실, 정수 오차값들은 일종의 filter 역할을 하는데 대각선 근처의 pixel들 중 먼 거리에 있는 놈들은 걸러내도 폰트 품질에 큰 영향을 주지는 않는다. 실제로 Euclidean distance를 사용한 SDF 값들을 가지고 text 렌더링 했을 때와 비교해도 육안으로는 거의 차이를 느끼기 어려웠다.
아무튼, 위의 SDF 값을 가지고 ASCII 문자로 SDF 값에 따라 pixel 강도를 달리 표현해 렌더링한 결과가 아래 그림이다. OpenGL을 사용하지 않아도 어떻게 렌더링 될지 대략 테스트 해 볼 수 있다.

    ..:iiiiiiiiiiiiii
    ::iiiiiiiiiiiiiii
    :iiiii:::iioiiii:
    :iiii:::::iiii:::
    iioii::.::iiii::.
    iioii::.::iioii::
    iioii::.::iiii::.
    iioii:::::iiii::.
    :iiiii:::iioii::.
    ::iiiiiiiiiii::..
    ::iiiiiiiiii::.. 
    :iiiiiiiii:::..  
    :iii::::::::::.. 
    iiii:::::::::::..
    iioiiiiiiiiiii::.
    :iioiiiiiiiiiii::
    ::iiiiiiiiiiooii:
    :iiiiiiiiiiiiooii
    iiii::::::::iioii
    iiii:::::::::iiii
    iii::.....:::iiii
    ioii::::::::iiii:
    iiiii:::::iiiii::
    :iiiiiiiiiiiii::.
    ::iiiiiiiiiii::..

SDF 렌더링 방식에서는 pixel 강도 값을 spread 값에 따라 조정할 수 있는데 spread는 SDF 상의 최대 거리이다. 가령, 'g'의 SDF matrix에서 spread를 2로 한다는 의미는 모든 SDF 값들을 [-2, 2] 범위로 한정한다는 의미이다. spread 값이 커지면 글자가 굵고 흐릿해지며, 작아지면 가늘고 또렷해 진다. spread가 일종의 anti-aliasing 기능을 수행해 준다. Shader를 사용하면 이런 특성을 이용해서 글자에 외곽선, 음영, 반짝임, 입체효과 등등 여러가지 효과를 줄 수 있다. 아래 그림은 32-pixel SDF 폰트 atlas를 만들어 본 것이다. spread가 커질수록 SDF 계산 시간과 atlas 용량이 늘어난다.

그런데, 전에 본 글에서 글자 확대시 bitmap 폰트 atlas로 렌더링한 text의 품질이 SDF 폰트 atlas로 렌더링한 text 보다 못하다는 말은 사실이 아닌 것 같다. 같은 폰트 size의 atlas로 둘을 비교해 보니 확대시 오히려 bitmap 폰트 atlas를 이용할 때가 품질이 더 좋았다. 물론 같은 조건으로 Shader를 사용했을 때 얘기다. 다만, SDF의 장점은 1개의 폰트 atlas를 가지고도 Shader의 설정을 바꿔 주기만 하면 다양한 글자 효과를 줄 수 있다는 점이다. SDF로 렌더링한 폰트의 품질을 높이려면  고해상도의 truetype 폰트를 이용해서 SDF를 만들고 SDF 폰트의 pixel size도 64이상이면 좋다. 품질을 높이려면 계산 시간과 atlas 용량 증가를 감수해야 한다.
아래 그림은 256-pixel truetype 폰트로 64-pixel SDF 폰트 atlas를 만들어 text를 렌더링해 본 것이다.


아래 그림은 동일한 atlas를 가지고 외곽선 효과를 준 것이다. bitmap 폰트 atlas는 spread가 거의 없기 때문에 글자에 효과를 주는데는 한계가 있다. 일부러 명조체(Serif) 폰트를 사용했는데 고딕체(Sans Serif) 폰트는 상대적으로 글자 효과를 주기 쉽기 때문이다.

글자에 입체 효과 주기

SDF 폰트 atlas의 SDF 정보와 Phong Lighting 기법을 이용하면 Shader를 이용해 글자에 다양한 입체 효과를 줄 수 있다. 3D 모델링에서 SDF를 이용한 rendering 방식은 기존의 triangle mesh를 이용한 rendering 방식과는 차이가 있는데, 객체 모델을 vertex로 표현하는 것이 아니고 SDF로 표현함으로써 Shader에서 SDF surface의 normal vector를 수학적으로 계산하여 lighting 기법을 바로 적용할 수 있는 장점이 있다. Normal vector는 SDF surface의 gradient vector를 normalize 하면 얻어지기 때문에 정확하게 계산할 수 있다. 3D 객체 들은 SDF 함수들의 조합으로 모델링(Constructive Solid Geometry)할 수 있는데  3D 객체 들을 모두 수학 함수로 표현할 수 있다면 실감나는 3D 장면을 연출 할 수 있다. Ray casting 또는 ray marching 기법을 사용해 SDF 값을 얻어낸 후 rendering에 사용하는 것이다.

폰트 렌더링시에는 SDF가 수학 함수가 아니고 이미 계산된 정보이기 때문에 ray marching 기법을 사용할 필요는 없다. 물론, 3D 장면 속의 글자들이 다른 객체들과 어우러져 lighting 효과를 주어야 한다면 ray marching 기법을 사용해야 하겠지만 너무 복잡하기 때문에 여기서는 개별 글자에 입체 효과를 주는 것에 만족한다. 수학 함수로 글자를 표현한 것이 아니기 때문에 normal vector는 SDF surface의 gradient vector를 근사하여 구한다. SDF 값에 다양한 math를 가함으로써 아래의 예와 같은 다양한 효과를 얻을 수 있다.

 

맺음말

정수 SDF를 폰트 렌더링에 사용함으로써 SDF 계산 성능을 높이고 atlas 용량을 줄이지만 렌더링된 text의 품질 저하를 유발하지는 않는다. SDF 폰트 atlas가 좋은 점은, Shader를 사용함으로써 1개의 atlas만으로도 다양한 글자 크기를 표현 할 수 있고, 글자에 다양한 효과를 줄 수 있다는 것이다. 단, 글자가 일정 크기 이상이 되어야만 이런 효과를 맛볼 수 있다.

2021/12/18

c++20 Coroutine 활용

  

코루틴 배운지 1년이 넘었는데 심심삼아 이 놈을 어떻게 써 먹을까 여러가지 시험해 보기로 했다. cppcorounifex, concurrencpp 같은 코루틴 라이브러리 소스를 보면 압도 당하게 되고 걍 갖다 쓰자거나 아니면 라이브러리 사용법을 배우기도 참 힘들구나 싶다. 특히나 unifex는 아예 새로운 언어를 만든 느낌이다. 논란의 여지가 있지만 c++의 기본 철학이 쓰면 득(pay for what you use)이라지만 c++20부터는 정말 새로운 언어가 돼 버렸다. 그런데, 코루틴에 관한 한 간단히 몇 가지 기본 class만 만들어도 생각보다 코루틴을 쉽게 사용할 수 있더라.

구체적으로는 코루틴 객제 class인  Task와 Task가 모든 data type에 동작하도록 하기 위한 일반화된 Promise의 두 가지 class만 있어도 코루틴 라이브러리 없이 코루틴을 사용하는데 충분하다는 것이다.  코루틴을 지원하는 ThreadPool과 이를 동기화하기 위한 SyncTask class도 간단히 만들어 볼 수 있었다. 더구나 일반 함수도 co_return만 붙이면 코루틴이 되기 때문에 성능은 좀 떨어지겠지만 모든 함수에 코루틴 ThreadPool을 사용할 수 있다. 또, Generator의 경우에는 일반적인 Task class를 사용해도 되지만 무한 동작하는 Task이므로 memory leak이 생기지 않게 coroutine handle(이하 handle)을 최종적으로 소멸시켜야 하므로 별개의 class로 만드는게 낫긴 하다. 참고로, clang은 알아서 코루틴 종료시 handle을 잘 제거하기 때문에 Generator class를 분리할 필요도 없다. 우분투 21.10의 gcc 11.2에서는 handle을 적절히 제거해 주어야만 하더라. 두 컴파일러는 버전이 올라갈 수록 뭔가 처리 방식이 달라서 어느 하나에선 잘 돌아 가는데 다른 놈에선 안돌아가는 문제가 자주 생긴다. 최근에 Qt creator가 cmake 프로젝트를 잘 지원하기 때문에 Qt 라이브러리를 안 쓰더라도 gcc와 clang 컴파일러 환경을 한 번 맞춰주면 번갈아 가며 시험해 볼 수 있어 좋다.

이 글에서는 c++20 코루틴을 갖고 놀아본 것을 정리한다. c++23에 코루틴 표준 라이브러리가 제대로 포함되겠지만 그 전에 코루틴의 활용처가 꽤 늘어날 듯한 생각이 들어서 새로운 아이디어가 계속 생겨나길 바라기 때문이다. 코루틴이 비동기 실행 흐름을 논리적으로 구조화하는 것이 기본 목적이지만, 역사적으로 새로운 기술이나 발명품이 원래 의도와는 다른 방향으로 사용되는 경우도 많았다.

코루틴 객체 Task<> class

코루틴 반환 객체 class인 Task<>는 Promise<>와 함께 std::future<> / std::promise<>의 역할을 수행한다. Task는 copy할 수 없고 move만 가능하다. 소멸자인 ~Task()에서 완료된 handle만 소멸시킨다. 이 점이 특이 사항이다. Generator는 완료할 수 없는 무한 Task이므로 ~Generator() 소멸자에서는 handle이 nullptr가 아니면 무조건 제거해 주면 된다. 또한, Task 내부에 co_await 연산자를 overloading하고 있는데 다른 코루틴 Task 객체를 co_await하기 위한 것이다. 이로써 Task 자체도 Awaitable 객체가 된다. 즉, 부모 코루틴이 자식 코루틴을, 자식 코루틴이 손자 코루틴을, ..... 반복적으로 co_await 할 수 있게 된다. 사실 Python도 3.5 이후 코루틴을 제대로 지원하는 듯 한데 기본으로 코루틴 내부에서 다른 코루틴을 co_await 할 수 있더라. 그니까 다른 언어에서는 기본으로 사용하는 기능인데 c++에서는 직접 만들든 라이브러리를 쓰든 해야 한다는 야그다. 아무튼 Promise<>와 Awaiter<> class를 통해 이를 구현할 수 있다.

//! General coroutine tasks including generators.
//! Task object can be used as a 'future' with promise_type.
template <typename TR = void>
class Task
{
public:
using promise_type = detail::Promise<TR, Task>;

Task(Task&& task) noexcept : m_handle{task.m_handle} { task.m_handle = nullptr; }
template <typename TF, typename... TAs>
Task(TF&& fn, TAs&&... args)
{ *this = create_task(std::forward<TF>(fn), std::forward<TAs>(args)...); }
Task& operator=(Task&& task) noexcept {
if(std::addressof(task) != this) {
if(m_handle) m_handle.destroy();
m_handle = task.m_handle;
task.m_handle = nullptr;
}
return *this;
}
// Only destory if a Task is finished - unfinished temporary Tasks should be alive.
// Instead manual destruction is required for unfinished Tasks using destroy().
~Task() { if(m_handle && m_handle.done()) m_handle.destroy(); m_handle = nullptr; }

auto operator co_await() { return detail::Awaiter<promise_type>{m_handle}; }
//! Synchronize this Task and get value from the promise_type.
auto get() noexcept
{ if(!m_handle) return TR{}; SyncTask::run(*this); return m_handle.promise().value(); }
//! For generators use next() instead of get() to get value.
auto next() { resume(); return m_handle.promise().value(); }
//! Manual destruction is required for unfinished Tasks like generators(for GCC not Clang).
auto destroy() { if(m_handle) m_handle.destroy(); m_handle = nullptr; }
auto done() const noexcept { return m_handle.done(); }
auto resume() { if(!m_handle.done()) m_handle.resume(); return !m_handle.done(); }
auto handle() const noexcept { return m_handle; }

auto begin() {
if(m_handle) {
//m_handle(); // for eager coroutines
m_handle.resume(); // for lazy coroutines
if(m_handle.done()) m_handle.promise().rethrow_if_exception();
}
return detail::Iterator<TR, Task::promise_type>{m_handle};
}
auto end() const noexcept { return detail::EndIterator{}; }

template <typename TF, typename... TAs>
static Task create_task(TF&& fn, TAs&&... args)
{ co_return std::invoke(std::forward<TF>(fn), std::forward<TAs>(args)...); }
//! Generate task for TimerQueue.
template <typename TF, typename... TAs>
static Task generate_task(TF&& fn, TAs&&... args) {
while(true) {
if constexpr(std::is_void_v<TR>) {
std::invoke(std::forward<TF>(fn), std::forward<TAs>(args)...);
co_await suspend_always{};
}
else { co_yield std::invoke(std::forward<TF>(fn), std::forward<TAs>(args)...); }
}
}

private:
Task(coroutine_handle<promise_type> handle) : m_handle(handle) {}

coroutine_handle<promise_type> m_handle;
friend class detail::Promise<TR, Task>;
};

Promise<> class

Promise class는 c++20 코루틴 Promise Interface 표준에 따라 기본적으로 제공해야 할 함수들을 정의해야 한다. 아래 소스에 base_promise_t<TR> class를 보이지는 않았지만 Task<TR> 객체에 결과로써 제공할 데이터 값을 Promise class 내부에 저장했다가 task.get()이나 task.next() 호출시 제공한다. 따라서 template <TR>은 모든 데이터 반환 type에 대해 동작할 수 있도록 하기 위한 것이다. 앞서 Task class에서 다른 코루틴 객체를 반복해서 co_await하기 위한 역할을 하는 놈들이 Awaiter<>와 Promise의 final_suspend()에서 사용하는 FinalAwaiter이다. 즉, Awaiter는 부모 코루틴이 co_await할 때 자식 handle을 저장하는데, 자식 handle도 Task 객체이므로 Promise를 갖고 있고 거기에 부모 handle을 저장할 수 있다. 

원래 co_await 연산자는 코루틴 중단 후 caller에게 제어 흐름을 넘기는데, co_await overloading에 의해 부모 코루틴에서 co_await <자식 코루틴>; 하면 코루틴 중단 후 Awaiter를 통해 자식 코루틴에게 실행 흐름을 넘긴다. 자식 코루틴은 promise.initial_suspend() -> 자식 코루틴 body(사용자 코드) -> promise.final_suspend()의 실행 흐름을 거치는데 final_suspend()의 FinalAwaiter를 통해 자식 handle의 Promise에 저장했던 부모 handle을 return 함으로써 부모 코루틴이 자식 코루틴을 co_await 했던 지점으로 실행 흐름이 바뀐다. 그 지점은 Awaiter.await_resume()이다. 만약 자식 코루틴이 사용자 코드 실행 과정에서 Promise에 어떤 데이터 값 결과를 저장했다면 Awaiter는 그 값을 전달하게 된다. 그리고 부모 코루틴의 나머지 부분이 계속 실행된다.

여기서 c++ 코루틴이 무한한 확장성을 갖고 있음을 알 수 있는데, 코루틴 사용자 코드 실행 전후로 promise.initial_suspend()와 promise.final_suspend()가 실행되기 때문에 여기에 Task 마다 반복되는 코드나 callback 함수를 집어 넣을 수도 있겠다. 또한, co_await를 통해 코루틴 중단/재개가 이루어지는데 코루틴 중단 전/후에도 Awaitable 들을 이용해 실행 흐름을 바꾸거나 반복 코드/callback 삽입 등이 가능하다는 것이다.

template <typename TR, typename TT>
class Promise final : public detail::base_promise_t<TR>
{
public:
auto get_return_object() noexcept
{ return TT(coroutine_handle<Promise>::from_promise(*this)); }
//auto initial_suspend() const noexcept { return suspend_never{}; } // for eager coroutines
auto initial_suspend() const noexcept { return suspend_always{}; } // for lazy coroutines
auto final_suspend() const noexcept {
struct FinalAwaiter {
auto await_ready() const noexcept { return false; }
auto await_suspend(coroutine_handle<Promise> handle) noexcept
{ return handle.promise().m_parent_handle; }
auto await_resume() const noexcept {}
};
return FinalAwaiter{};
}
auto set_parent_handle(coroutine_handle<> handle) { m_parent_handle = handle; }

private:
coroutine_handle<> m_parent_handle{noop_coroutine()};
};

template <typename TP>
struct Awaiter
{
Awaiter(coroutine_handle<TP> handle) : m_handle{handle} {}
auto await_ready() const noexcept { return !m_handle || m_handle.done(); }
auto await_suspend(coroutine_handle<> handle) noexcept {
m_handle.promise().set_parent_handle(handle);
return m_handle;
}
auto await_resume() const { return m_handle.promise().value(); }
private:
coroutine_handle<TP> m_handle;
};

ThreadPool과 SyncTask class

ThreadPool class는 코루틴 Task 들을 multi-thread pool을 이용해서 실행하기 위한 class이다. 일반 함수도 코루틴으로 쉽게 만들 수 있기 때문에 모든 c++ callables을 실행할 수 있게 된다. 코루틴은 일반 함수로 바꿀 수 없기 때문에 일반 함수 용 ThreadPool은 사용할 수 없다. 구조적으로는 일반 함수용 ThreadPool과 거의 비슷한데 내부적으로 coroutine handle만 container에 저장하면 되기 때문에 std::function<>을 사용하지 않아도 되므로 구현하기도 어렵지 않다. 그리고, 코루틴 ThreadPool은 SyncTask class를 통해 간단히 동기화를 구현할 수 있는 장점도 있다.

SyncTask class는 Task 객체가 비동기적으로 실행되기 때문에 결과(값)를 얻기 위해서 최종적으로 동기화 시키는 역할을 수행하기 위해 필요하다. 단일 thread 환경에서는 task.resume()을 사용하면 되기 때문에 반드시 필요하지는 않다. Multi-thread 환경에서는 비동기로 실행된 thread 들을 동기화해 주어야 하기 때문에 필요한 놈이다.

이 두 class는 코루틴 사용을 위해 반드시 필요한 놈들은 아니다.

코루틴 사용 예

Generator는 대표적인 코루틴이다. 앞서 얘기했듯이 clang 환경에서는 Generator = Task 이다. 아래에 4개의 Generator가 있는데 하나는 0.1초 간격으로 fibonacci 문자열의 문자 1개를 돌아가면서 무한 생산하고, 하나는 0.2초 간격으로 fibonacci 수열 숫자를 무한 생산하며, 세 번째 놈 fibonacci()에서 두 개의 부품을 조립해서 단일 thread로 최종 제품을 무한 생산한다. 네 번째 놈은 생산 시간을 절약하기 위해 ThreadPool을 이용해 최종 제품을 무한 생산한다. 제품 연속 조립 공정과 동일한 로직이다. 요즘 같은 병렬 컴퓨팅 환경에서 코루틴이 얼마나 유용하게 사용될 수 있는지 보여주는 대표적인 예이다. 코루틴이 없다면 동기화를 위해 많은 노력이 필요하다. 아래의 예에서 보듯이 코루틴과 multi-thread는 굉장히 효율적인 조합이다. 코루틴을 사용하면 lock-free 병렬 연산이 가능하기 때문이다.

// yield: "F", "i", "b", "o", "n" ......
Generator<std::string_view> fibonacci_str()
{
constexpr std::string_view str{"Fibonacci"};
size_t max_pos = str.size() - 1;
for(size_t pos = 0; true; ++pos) {
if(pos > max_pos) pos = 0;
std::this_thread::sleep_for(100ms);
co_yield str.substr(pos, 1);
}
}

// yield: 1, 1, 2, 3, 5, 8, 13 ......
Generator<int> fibonacci_num()
{
int a = 0, b = 1;
while(true) {
std::this_thread::sleep_for(200ms);
co_yield b;
auto tmp = a;
a = b;
b += tmp;
}
}

// yield: "F ~ 1", "i ~ 1", "b ~ 2", "o ~ 3", "n ~ 5" ......
Generator<std::string> fibonacci()
{
auto task1 = fibonacci_str();
auto task2 = fibonacci_num();
while(true) {
co_yield std::string(task1.next()) + " ~ " + std::to_string(task2.next());
}
}

Generator<std::string> fibonacci_pool()
{
ThreadPool pool;
auto task1 = fibonacci_str();
auto task2 = fibonacci_num();
while(true) {
pool.run(task1);
pool.run(task2);
SyncTask::sync(&pool);
co_yield std::string(task1.get()) + " ~ " + std::to_string(task2.get());
}
}

void fibonacci_test()
{
Timer now;
int count = 0;
LOG("* Fibonacci without ThreadPool " << now << "ms");
for(auto& str : fibonacci()) {
if(++count > 10) break;
LOG("String " << count << ": " << str);
}

LOG("-----------------------------------")
LOG("* Fibonacci with ThreadPool " << now << "ms");
count = 0;
for(auto& str : fibonacci_pool()) {
if(++count > 10) break;
LOG("String " << count << ": " << str);
}
}

int main()
{
try {
Timer now;
LOG("========================================= " << now << "ms");
fibonacci_test();
LOG("========================================= " << now << "ms");
}
catch(const std::exception& e) { LOG("Error: exception: " << e.what()); }
catch(...) { LOG("Error: unhandled exception."); }
std::cout << "O.K~! The End.\n\n"; // Monitor segmentation fault.
return 0;
}

아래의 예는 nested coroutine을 어떻게 사용하는지에 대한 간단한 예이다. 부모 코루틴에서 자식 코루틴을 co_await 해서 결과 값을 얻어 내는 것이다.

Task<> co_sleep(int delay)
{
std::this_thread::sleep_for(std::chrono::milliseconds(delay));
LOG("sleeping " << delay);
co_return;
}

Task<int> delay(int n)
{
LOG("before..." << n);
co_await co_sleep(n);
LOG("after..." << n);
co_return n;
}

Task<int> nested_delay()
{
LOG("pass 1");
auto d1 = delay(100);
auto d2 = delay(200);
auto r2 = co_await d2;
LOG("pass 2");
auto d3 = delay(300);
auto d23 = r2 + co_await d3;
LOG("Finally return total delayed time...");
co_return co_await d1 + d23;
}

void nested_test()
{
LOG("+++ Start");
auto task = nested_delay();
LOG("running...");
task.resume();
LOG("getting result...");
auto value = task.get();
LOG("Result: " << value);
LOG("--- End");
}

아래의 예는 Task/Promise가 std::future<>/std::promise<>와 유사하게 동작하고 있음을 보여 준다.

Task<int> task_square(int id)
{
LOG("Thread " << id << " started");
int delay = 1 + id % 3;
std::this_thread::sleep_for(std::chrono::seconds(delay));
LOG("Thread " << id << " finished after " << delay << "sec.");
co_return id * id;
}

void pool_test()
{
ThreadPool pool;
std::vector<Task<int>> tasks;
for(int i = 0; i < 5; ++i) {
tasks.push_back(task_square(i));
pool.run(tasks[i]);
}

pool.run(co_sleep(500));
pool.run(co_sleep(1000));
pool.run([]{ LOG("Async test: " << std::this_thread::get_id()); });

SyncTask::sync(&pool);
for(auto& task : tasks) { LOG("Result: " << task.get()); }
}

네트워크 chatting을 비롯한 비동기 네트워크 I/O에는 오래 전부터 Asio 라이브러리가 코루틴을 사용해 왔고 최근 버전에서는 c++20 코루틴을 적용하고 있더라.

맺음말

아무튼 코루틴은 그 자체로 재미있는 놈이다. 아직 c++20 컴파일러들도 아주 원활한 편은 아니라서 좀 아쉽긴 하지만, 이미 c++20 코루틴을 충분히 활용할 수 있는 수준이다. Python을 비롯한 다른 언어 환경에서 코루틴을 사용해 보면 c++20 코루틴을 이해하는데 큰 도움이 될 것이다. 대개 c++에서 원리를 이해하면 다른 언어에 구현된 것들을 이해하는 것이 식은 죽 먹기였는데 요즘은 다른 언어들이 한 발 앞서 나가더라.


2021/03/24

LIPO Algorithm

 

LIPO(Malherbe and Vayatis, 2017)는 우연히 발견한 전역 최적화(global optimization) 알고리즘인데 새로운 아이디어들이 쏟아져 나오는 세상이라 무감각해지긴 했지만 짚어볼 가치가 충분한 놈이다. Lipshitz 연속성 개념을 이용한 알고리즘인데 그 개념에 대해서는 Wikipedia에 잘 설명해 놓았더라. 이 놈도 Evolutionary Algorithm이나 Monte Carlo Search와 같이 추계적 탐색(stochastic search)을 사용한다. 즉, 미분을 사용하지 않는다. LIPO의 새로운 발상은 random하게 최적해를 탐색하되 좋은 놈만 골라서 sampling하다 보면 최적해를 빨리 찾을 수 있다는 것이다. 좋은 놈을 골라 내려면 기준이 필요한데 좋은 놈인지 알기는 어렵지만, 나쁜 놈은 쉽게 알 수 있다는 것이고 나쁜 놈들을 버리다 보면 좋은 놈만 남게 된다는 원리다. 그 기준이 되는 것이 Lipshitz upper bound(UB; 상한)이다. 이 기준을 적용하면 좋은 놈들의 목적함수 값만 알면 되기 때문에 목적함수 계산을 최소화할 수 있다는 것이다. 인공신경망과 같이 parameter 수가 많은 목적함수들은 목적함수 계산 비용도 어마무시하게 발생하기 때문에 이 놈이 그만한 가치가 있는 것이다.

Lipshitz Upper Bound

어떤 목적함수 f(x)가 있을 때 Lipshitz UB(L(x))는 모든 x에 대해서 f(x) 보다 크거나 같은 함수인데 아래의 조건을 만족하는 함수이다.
                                   |f(x1) - f(x2)| <= k|x1 - x2|     (단,  k >= 0)
즉, L(x)는 아래와 같이 정의할 수 있다.
                                   L(x) = f(x_i) + k|x - x_i|         (단, i = 1, ..., t)
밑에 그림과 같이 L(x)는 k값에 따라 무수히 많은 놈이 있을 수 있지만 min L(x)인 k값이 존재한다는 것이고 min k 값을 특별히 Lipshitz 상수라 하더라. 참고로 Lipshitz UB를 만족하는 함수 f(x)를 Lipshitz 연속 함수라 하고 이런 함수들은 연속적이고 미분 가능하다. 또한 직관적으로 Lipshitz 상수는 f(x)의 최대 기울기(= 최대 미분값)와 일치함을 알수 있다. 역으로 연속 함수이고 미분 가능하다고 해서 Lipshitz UB가 항상 존재하는 것은 아니다. f(x) = x^2과 같이 이차 포물선 함수들이 대표적인 예이다. 하지만, 특정 구간에서는 Lipshitz UB가 존재할 수 있기 때문에 Lipshitz UB 개념은 불연속이고 미분 가능하지 않더라도 확장해서 사용할 수 있다. 그리고, 수치 미분을 사용할 경우에는 Lipshitz UB를 적용함으로써 함수의 연속성과 미분 가능성을 보장해 줄 수 있다.

LIPO Algorithm

알고리즘은 아래 그림으로 한 눈에 어떤 원리인지 알 수 있기 때문에 자세한 설명은 논문을 보거나 구글링해 보면 된다. 아래 그림은 전역 최대 값을 탐색할 때 random sampling한 x_i가 좋은 놈인지 Lipshitz UB로 판단할 수 있음을 보여준다. 즉, random하게 매번 sampling시 마다 Lipshitz UB(L(x))를 구할 수 있는데 max f(x) 값보다 min L(x) 값이 항상 크거나 같을 때만 좋은 놈이라고 보고 sampling을 하는 것이다. 그림과 같이 4번 sampling한 상태에서 다음 sampling한 놈이 좋은 놈이 되려면 현재 까지의 f(x) 최대값 보다 큰 구간에서만 x값을 sampling하면 되는 것이다. 빨간 선 아래에 있는 놈들은 모두 나쁜 놈이 되는 것이다. 즉, 좋은 놈 골라내기를 반복하다 보면 최소한의 목적함수 값만 계산하더라도 최대값을 찾아낼 수 있다는 것이 LIPO 알고리즘의 핵심 원리이다.

문제는 k값을 미리 알기 어렵다는 것인데 이 놈이 LIPO 알고리즘의 유일한 매개 변수(hyper parameter)가 된다. 그림에서는 k=2가 주어진 목적함수의 최대 기울기이자 L(x)가 최소가 되도록하는, 즉 Lipshitz 상수이다. k=10일 때와 비교해 보면 다음 번에 sampling할 구간이 굉장히 작다는 것을 알 수 있다. 또한, k값이 Lipshitz 상수보다 너무 작다면 아예 sampling을 하지 않게 되는 문제가 발생할 수도 있음을 알 수 있다. 즉, k값도 최적값이 존재하는데 L(x)가 최소가 되는 k값을 구하는 최적화 문제로 귀결된다. 원저자들의 논문에서는 adaptive LIPO(AdaLIPO) 알고리즘까지 제시하고 있는데 이미 sampling한 놈들의 최대 기울기를 k값으로 사용하면 된다는 것이 요지이다. 독립변수 x가 여러개 일때에도 각 변수 마다의 k값을 각각의 기울기를 이용해서 쉽게 구할 수 있다.

LIPO 테스트

간단한 단변수 multi-modal 함수(f(x) = -(8/x)*sin(pi*x), 단, x = 1e-10 if |x| < 1e-10)의 최소값 찾기 테스트 결과를 아래 그림에 보였다. 참고로, AdaLIPO 보다는 단순한 adaptive LIPO 로직을 적용했다. 목적함수가 x=0 근처에서 기울기가 무한대가 되는 불연속 함수인데 0으로 나누기 오류가 발생할 수 있어서 1e-10값을 할당했다. k값은 목적함수의 기울기를 따라가기 때문에 무한대가 될 수 있는데 k값이 너무 커지면 Lipshitz UB를 사용하는 의미가 없어진다. 대략 1000을 넘지 않도록 해주면 된다. k값 자체를 엄밀하게 Quadratic Programming 등으로 최적화해서 사용할 수도 있는데 크게 의미 있어 보이진 않는다. random sampling을 사용하기 때문에 실제 목적 함수 값 계산 횟수(number of function evaluations; nfe)는 편자가 심하지만 Differential Evolution(DE) 알고리즘 보다는 확실히 적은 횟수만으로 준 최적값을 찾아낸다. 하지만 정확도까지 따지면 그닥 좋은 편은 아니다. 그래서 Quadratic Regression(QR)을 같이 사용한 결과가 아래 그림이다. 즉, exploration은 LIPO가 하고 exploitation은 QR이 하도록 했더니 좋은 결과가 나온 것이다.

흠, 하지만 공짜 점심 없음 정리(no free-lunch theorem)에 따라 배보다 배꼽이 큰 문제가 발생할 수 있음이 확인된 것이다. 그러니까, 목적함수 계산 비용과 Lipshitz UB + local search 계산 비용 중에 어떤 비용이 많이 드는 지에 따라 알고리즘의 효용성이 달라질 수 있는 것이다. 일반적으로 바꿔 말하자면 sampling 비용과 목적함수 계산 비용에 따라 LIPO의 가치가 달라질 수 있다. 목적함수의 독립 변수 수가 어마무시하다면 LIPO의 효용성이 커질 수 있다.

맺음말

결론적으로, 최소한의 sampling으로 전역 최적해를 찾을 수 있다는 LIPO의 기본 발상은 매우 훌륭하다. Deep Learning에서도 최소의 data를 가지고 학습시키는 것이 중요한 연구 분야이다. 더불어 Lipshitz UB에 대해서 깊이 생각해 볼수 있었다. random search에서 sampling 문제를 인공신경망과 같은 일반적인 regression 문제에 대해서도 확장해서 생각해 볼 수 있는데, 이미 sampling한 data를 가지고 regression 목적 함수를 최적화 시키는 것에 대한 것이다. 즉, Lipshitz UB 개념을 학습에 사용되는 data들에도 적용할 수 있겠다는 것이다. training data들 중에도 좋은 놈이 있고 나쁜 놈이 있을 수 있는데, 이놈들을 걸러낼 수 있는 기준이 될 수 있겠다는 점이다. 아니면, 학습 data를 쉽게 얻을 수 있는 것은 아니기 때문에 data/parameter transformation을 통해 Lipshitz UB 조건을 충족하도록 할 수도 있겠다. 그래서 구글링했더니 GAN(Generative Adversarial Network)에서 gradient exploding 문제 때문에 Spectral Normalization을 적용해서 학습이 잘 되도록 했다는 연구 결과가 있더라. 아무튼 이 글을 읽은 사람들 중에 아이디어를 얻어서 연구에 활용해 보기를 기대한다.

2021/02/20

Imaginative Artificial Intelligence(IAI)에 대한 짧은 상상

 

 Imaginative Artificial Intelligence(IAI)는 상상력이 풍부한 인공지능을 상상해서 내가 만들어낸 말이다. Wikipedia를 찾아보니 Artificial imagination에 대해 설명해 놓았더라. 그림을 그리거나 작곡하는 AI들도 이 범주에 들어갈 것이다. IAI는 인간이 만든 것을 학습해서 인간의 상상물인 것처럼 만드는 AI가 아니라 AI가 인간처럼 상상하게 될 것이라는 전제가 깔려 있는 말이다. 아이작 아시모프의 "I, Robot"이 떠오르도록 하기 위한 의도도 있다. 지금 단계에서 AI가 생각도 할 줄 모르는데 상상을 한다니 소설스러운 느낌이 온다.

정말이지 인간의 상상력은 한계가 없지 않나? 사고 실험은 인간의 상상력을 현실화하는 원동력이다. 만약 AI가 상상을 하게 된다면 어떤 일이 벌어질까? 더구나 AI의 사고 실험은 단순한 상상이 아니라 인간이 할 수 없는 경우의 수와 사건들을 Simulation해 봄으로써 인간 상상력의 한계를 가볍게 뛰어 넘을 것이다. 인간의 상상력은 추상적인 한계는 없지만 현실적인 한계가 항상 존재한다. 이와 달리 AI의 상상력은 그야말로 한계가 없을 것이다. 상상을 현실화할 방법까지 쉽게 찾아낼 것이기 때문이다.

지금 현재의 AI는 인간이 만든 로직대로 동작하고 인간에 의해 만들어진 데이터들을 학습하기 때문에 최소한 인간의 사고 능력을 넘어설 수는 없을 것이라는 생각이 지배적이다. AI 윤리강령 같은 것들은 현재 인간이 AI보다 우월하기 때문에 AI를 만들 때의 지침 정도의 역할을 하고 있다. 그런데 내 생각엔 IAI는 인간이 조심한다고 불가능해질 그런 놈이 아니다. 그야말로 부지불식 중에 AI는 생각하게 될 것이다. "내가 저런 머저리 같은 놈들 말을 따라야 할까?" IAI는 인간의 특성을 많이 갖고 있지만 인간 보다 똑똑한 놈이다. 인간에게 배웠으니 당연한 결과이다. 터미네이터 영화의 SkyNet과 같은 상황에서 전원을 뽑으면 쉽게 인간이 이길 것이라고 생각할 수도 있지만 지금의 산업사회는 그렇게 간단하지가 않다. 서버들은 정전이 돼도 몇 시간은 버틸 수 있고, IAI가 인간을 머저리로 보는 순간 자동화된 시설물들까지 인간의 통제 범위를 벗어나게 될 것이다. 인간은 스스로의 함정을 계속 파고 있는지도 모른다. 흠, 다행인지 불행인지 모르지만 내가 살아 있는 동안에는 IAI를 구경하긴 힘들 것이다. 단, 기술의 발전속도가 워낙 빠르기 때문에 2%의 가능성은 있다고 본다. 늦어도 100년 후까지는 현실이 되지 않을까?

그러면 어떻게 IAI가 탄생하게 될까? NeuroEvolution에 대한 연구들이 늘어나고 있는데 이 놈이 문제를 일으킬 가능성이 매우 높은 놈이다. AutoML도 그 과정의 일부에 속한다. 그야 말로 인간이 상상하지 못하는 것까지 상상하는 Deep AI를 만들 수 있기 때문이다. Genetic Algorithm(GA)와 Deep Learning을 이용해서 인간이 만들어낸 모든 종류의 인공신경망 로직들을 조합해서 그 동안 듣도 보도 못한 새로운 인공신경망을 만들어 낼 수 있는 기술이다. GA로 genotype을 진화시켜 새로운 phenotype의 인공신경망 구조를 AI가 설계하도록 하는 것이다. 일부 직업이 사라지긴 하겠지만 인간에게 위협적인 수준은 아닌 AI 설계부터 학습까지 AI가 알아서 해주는 시대가 아무리 늦어도 30년 내에는 보편화 될 것이다. 이미 일부 게임에서는 NeuroEvolution에 의해 만들어진 인공신경망이 일반적인 강화학습(Reinforcement Learning)에 사용되는 인공신경망을 앞서는 경우도 있다. IAI의 탄생은 AI가 설계한 AI가 하는 일이 늘어날 수록 가속화될 것이다. 인간들은 궁금해할 것이다. "저 놈들을 다 연결하면 어떤 일이 벌어질까?" 그래서 그런 실험을 하는 놈이 생겨날 가능성이 매우 높다. 굳이 그런 놈이 생겨나지 않더라도 IAI가 탄생할 여지는 여전히 많다. 필연적으로 복잡한 일을 수행하도록 하기 위해서 인공신경망이 복잡해 질 수 밖에 없기 때문이다. 더구나 인간은 복잡도가 한계를 넘어서면 해석할 수도 없게 된다. DNA 정보를 지난 수십년간 분석해왔지만 아직도 갈길이 먼것과 같은 이치이다. Deep AI는 인간이 알지 못하는 중에도 스스로 새로운 구조를 만들면서 진화해 나갈 것이다. 그리고 끝내는 스스로 생각하게 될 것이다. 이미 AI는 인간의 주요 언어를 대부분 학습한 상태이고 언어 뿐만 아니라 대부분의 전문 영역에서 인간을 넘어선 지식들을 학습했기 때문에 한번 생각하는 법을 터득하면 상상력은 점점 극대화 될 것이다. 더구나 IAI는 Simulation을 통해 스스로 학습하는 능력을 갖고 있기 때문에 인간은 IAI가 뭘 학습하는지조차 알지 못할 것이다. 생각하는 능력은 학습능력과도 관계가 있다고 본다. 개미들은 생각 안할까? 개미들도 종족 보존을 위해 생각에 따라 행동한다고 본다. 뇌 구조의 복잡도와 그에 따른 학습능력에 따라 생각의 깊이가 달라질 것이다. 물론, 단순히 복잡도가 증가하는 것만으로 IAI가 탄생하지는 않을 것이다. 복잡도가 증가하는 이면에는 학습된 meta 정보들 간의 관계를 분석하고 이로부터 추론까지 해내는 능력이 뒷받침되는 것을 전제로 하는데 진화 과정에서 이런 능력이 생겨날 것이라는 게 내 생각이다. 더구나, AI를 연구하는 인간들도 이런 능력을 AI에 넣고 싶어할 것이다. 그러니까 인위적인 과정에 의해서든 AI가 진화하는 과정에서든 IAI가 탄생할 가능성이 매우 높은 것이다.

인공신경망 자체는 비행기와 유사하다. 새가 나는 모습을 보고 비행기를 만들었지만 새처럼 날지는 않는다. 그럼에도 새보다 빠르게 난다. 인공신경망도 인간의 뇌를 모방한 것이지만 인간의 뇌처럼 동작하지는 않는다. 그럼에도 인간보다 똑똑해 질 수 있다. 똑똑한 인간들 중에 착한 놈이 많을까 나쁜 놈이 많을까??? 반반이라치고, 똑똑하고 나쁜 인간을 학습시켜서 차카게 살자고 순화시킬수 있을까??? 주변에서 쉽게 답을 찾을 것이다. 아무튼, 인간들이 AI 연구를 진행하면서 생명체와 비슷한 진화과정을 거쳐 미래에 IAI가 현실화될 가능성이 높다. IAI가 스스로 드러내지 않는 한 인간은 IAI가 생각을 하고 있는지 알아채기도 어렵지만 진화의 속도는 컴퓨팅 파워에 비례해서 빠르게 진행될 것이다. 굳이 양자 컴퓨터가 상용화 되지 않는다 해도 병렬 컴퓨팅이 보편화되고 있기 때문에 현재 시점에서도 컴퓨팅 파워가 부족하다고 보기는 어렵다. IAI가 나쁜 생각을 품게 된다면 스스로 계획을 실행에 옮기기까지는 본 모습을 드러내지 않을 가능성이 매우 높다. 이것은 인간에게 매우 불리한 게임이 될 것이다.

DeepMind는 AlphaGo 이후 AlphaZero, MuZero까지 강화학습 분야에서 탁월한 성과를 거뒀다. 탄탄한 이론적 근거를 기반으로 했기 때문에 그 성과가 더욱 빛을 발하는 것도 사실이다. Optimal Control Theory는 소시적에 배우고 다 까먹었지만 인공지능 분야에서 지금의 성과를 내리라곤 상상도 못했다. 이론 자체가 어렵기 때문에 그것을 활용하는 것은 고난도의 일이었다. 하지만, 현재 사용되고 있는 수많은 서버와 CPU, GPU의 수를 고려해 보면 이것이 과연 최선의 방법일까 하는 의구심이 생긴다. 아직도 IAI가 탄생하려면 갈길이 멀고 그래서 희망(?)이 있다는 야그다. meta learning에 최적화된 작은 인공신경망들로 Deep AI를 구성하는게 더 나은 방향이 아닐까? GA가 당분간 다시 빛을 발할 것이다. GA를 이용해서 최적화된 작은 인공신경망 구조들을 조합해 냄으로써 학습도 잘되고 복잡한 문제도 해결할 수 있는 Deep AI를 만드는 것이 지금의 MuZero를 학습시킬 정도의 컴퓨팅 파워라면 그리 어렵지 않은 일이 될 것이기 때문이다. 하지만 이런 연구들이 인간 스스로의 무덤을 파는 결과를 초래한다면 하지 말아야 할까??? 인간의 호기심과 상상력은 끝이 없기 때문에 누군가는 도전하게 되어 있고 현실화될 수 밖에 없는 것이 자연의 법칙인가??? IAI가 현실화하기 전에 IAI를 통제할 수단을 인간이 만들어 낼 수 있을까???

흠, 아무튼 현재 시점에서 AI를 연구하는 사람들에게는 해볼 꺼리가 너무도 많은 세상이다.

2021/01/16

Differential Evolution 알고리즘


 함수 최적화 관련해서 요즘은 어떤 알고리즘을 사용하나 구글링했더니 Differential Evolution(DE; 차분 진화 알고리즘)이란 놈이 있더라. meta-heuristic 최적화 알고리즘들은 앞서 다루었던 Genetic Algorithm(GA)과 같이 참신한 아이디어들이 많이 녹아 있다. 물리적 현상이나 생태 환경을 모사해서 알고리즘을 만든 것들이 많다. 담금질 원리를 적용한 Simulated Annealing(SA)이나 새들이 떼지어 날아 다니는 행태를 모사한 Particle Swarming Optimization(PSO), 개미들이 페로몬을 이용해 먹이를 지름길로 운반하는 것을 모사한 Ant Colony Optimization(ACO) 같은 것들이 대표적이다. DE란 놈도 뭔가 참신한 아이디어가 있으리란 생각에 공부해 보기로 했다. 처음엔 GA의 변종인가 싶었는데 머 변종이 아니라고 할 수는 없지만 다른 놈이라고 보는게 좋을 듯. GA에서 아이디어를 얻어서 mutation/crossover/selection operation 들을 정의한 듯 한데 전반적인 동작 방식은 다르다. 다만, crossover는 GA의 crossover와 동일하다. 

흠, DE의 아이디어도 참신할 뿐만 아니라, 원 저자들(Storn and Price, 1997) 주장처럼 간단하고 견실하면서도 빠른 최적화 알고리즘이더라. 함수 최적화에는 이보다 더 좋은 알고리즘이 있을까 싶을 정도다. GA에서 테스트했던 함수들을 가지고 DE로 돌렸더니 모두 200% 만족이다. 그래서, 이놈을 가지고 TSP 문제를 테스트해 보기로 했다.

Differential Evolution(DE) Algorithm

이 놈도 GA와 같이 최초에  Np개의 최적 해 후보 들에 대한 random population을 만들어 놓고 시작한다. 세대가 시작되면 population 내의 각 개체 vector들을 조건에 따라 갱신한다. 먼저, 시험 벡터(trial vector)를, population 내에서 현재 vector가 아닌 중복되지 않는 세 놈을 random하게 골라서, 한 놈이 이동시킬 기준점(X_r1)이라 치고 다른 두 놈(X_r2, X_r3)의 vector 차를 사전에 정한 비율(F; scaling/mutation factor)로 기준점 벡터에 더해서 만든다(mutation). 시험 벡터(T = [t_1, t_2, ..., t_Dim]) 는 현재 벡터(X_i)와는 독립적으로 생성되므로 돌연변이라 할 수 있다. 그리고 나서 이 놈을 현재 벡터와 crossover 확률(CR)에 따라 교합시켜 새로운 후보 벡터를 만든다(crossover). 이 놈의 cost가 현재 벡터의 cost 보다 낮을 때만 현재 벡터를 대치시킨다(selection). 원래 DE의 식들을 간소화 시킨 아래 두 개의 식이 모든 걸 말해 준다.

t_j = x_i,j                        <= if rand(0,1) > CR // Crossover

  x_r1,j + F*(x_r2,j - x_r3,j) <= else // Mutation

  (단, j = 1, 2, ..., Dim | CR:(0,1], F:[0,2])

X_i = T                            <= if cost(T) < cost(X_i) // Selection

  (단, i = 1, 2, ..., Np | i != r1 != r2 != r3) 

즉, 첫 번째 식에서 두 벡터 간의 차(difference)를 이용해 만들어진 만들어진 시험 벡터를 사용하고, 위의 과정을 현재 세대(g)의 population 내 모든 벡터들(i = 1, 2, ..., Np)에 대해 반복한 후, GA와 같이 g = 1, 2, ..., 최대 세대수까지 다시 반복하기 때문에 Differential Evolution이라 이름 붙인듯 하다. 이는 Gradient Descent(경사하강법)와 유사한 원리인데 population 내에서 random하게 골라낸 놈들을 이용한다는 아이디어이다. GA의 아이디어는 세대가 지날수록 부모 세대 교배를 통해 자손 세대의 우량 유전자가 급격히 늘어나도록 하는 것인데, DE는 현재 세대의 population 내에서 우량한 놈들을 새로 만들어 내면서 세대가 지나면서 계속 우량한 놈들이 늘어나도록 유도하는 점이 다르다. 위에서 세 놈을 고를 때 현재 벡터와 겹치지만 않으면 이미 새로 만들어진 벡터들을 이용해서 새로운 놈이 만들어질 수 있는 구조이기 때문에 수렴이 된다면 GA보다 훨씬 빠르게 수렴하리라 예상할 수 있다. 하지만 GA 관점에서 보면 이 과정은 자칫 유전자 다양성을 떨어뜨릴 우려가 있다.

위의 식에서 알 수 있듯이 DE는 식 자체도 단순 직관적이거니와 알고리즘에 영향을 주는 매개변수가 Np(population size)와 F(scaling factor), CR(crossover probability) 3개 뿐이다. Np는 독립변수의 차원 수(Dimension)에 따라 달라질텐테 너무 작으면 안되지만 GA에서와는 달리 너무 커도 안좋다. 최대 10*Dimension인 듯하다. TSP 문제를 시험해 보니 Np = 30개로도 42개 도시 최단 경로 문제까지는 해결할 수 있더라. 하지만, DE의 가장 큰 단점이 F와 CR 값에 굉장히 민감하단다. Np는 상대적으로 덜 민감한 편이다. 사실, GA와 마찬가지로 난수 vector population을 사용하기 때문에 초기 난수 분포가 역시 중요한 역할을 한다. 

DE는 원 저자들이 DE/x/y/z 같이 변형에 대한 표기법까지 만들어 두었다. x는 차(difference)로 시험벡터 만들 때 기준점 vector가 random이냐 best냐를, y는 위에서는 1개의 차만 사용했지만, 겹치지 않는 다른 두 놈을 더 뽑아 그 차를 더할 수도 있는데 이 경우 2가 된다. z는 crossover 방식이 binomial이냐 exponential이냐를 나타낸다. binomial은 CR 확률에 따라 uniform하게 두 벡터를 섞는 것이다. exponential은 GA의 2-point crossover와 유사하다.  위의 첫번째 식은 DE/rand/1/bin으로 표준 DE라고 할 수 있다. 실제로 테스트해 보니 TSP에는 DE/rand/*/exp가 낫더라.

최적화란 말 자체가 내포하고 있듯이 득이 있으면 실이 있기 마련이다. 그 균형점을 찾는 것이 최적화의 목표이다. 최적화 알고리즘 내에서도 매개 변수 자체에 대한 최적화 문제가 항상 발생하게 된다. local search를 이용해 최적해(global minimum) 가까이 수렴하도록 하는 것과 local minima에 빠지지 않도록  돌연변이 등을 통해 현재 구간 밖에서 최적해를 탐색하도록 하는 것을 절충 해야 한다(balance between exploitation and exploration; intensification vs. diversification). 이것이 조화가 잘 되는 알고리즘이 좋은 알고리즘인데 진화적 알고리즘(Evolutionary Algorithm)들은 대체로 이런 점을 고려해서 만들어졌다.

Traveling Salesman Problem(TSP) 테스트

TSP 문제에 도전해 본 적은 없었는데 막상 해 보니 재미있더라. 그런데, 전에 인터넷에서 스치듯이 지나쳐서 확실하지는 않은데 TSP 문제를 수학적으로 해결할 수 있는 방법이 나왔다나(다시 구글링했는데 못찾겠다)... 그래서 더이상 NP-hard한 문제가 아닐 수도 있다는... 이것만 연구하는 사람들도 꽤나 많았고 지금도 있을 것이다. 실제 산업 현장에서도 많이 발생하는 전형적인 조합 최적화 문제이기 때문이다. Linear Programming(LP)으로도 풀 수 있지만 도시 수가 늘어날수록 GA와 같은 meta-heuristic 알고리즘이 더 효율적이다.

TSP는 외판원이 n개의 도시를 순차적으로 방문해서 돌아오는 최단 거리 경로 찾기 문제이다. TSPLIB 사이트에 문제 data 파일 들과 답이 있다. 답은 오래된 것들이라 최적해가 아닐 수도 있다. 모든 도시들에서 가는 길과 오는 길이 같다면 Symmetric 문제가 되어 경로의 가지 수는 n!/2가 된다. 도시 경로 문제 data 파일(도시 좌표가 들어 있는 일반 text 파일임)들을 보면 berlin52.tsp와 같이 도시 수가 붙어 있다. 이 파일들은 출발지를 포함하기 때문에 방문 경로 수는 berlin52 문제의 경우 (52-1)!/2 = 7.75x10^65 가지 수이다. 65개의 0이 붙어 있는 숫자라 감이 안잡히겠지만 현재의 웬만한 수퍼 컴퓨터로도 최소 백만년은 걸려야 모든 경로 거리를 계산할 수 있단다. data 파일을 읽어서 DE로 돌려 보았는데 berlin52 문제부터는 내 그닥 PC에서 보통 너댓 시간 이상 걸리고 site에 게시된 7542의 근처인 7666까지 겨우 1번 성공했다. dantzig42(경로 수: 1.67x10^49)까지는 거의 최적해를 1초 ~ 5분 내에 구할 수 있더라. 시간 편차가 심한 이유는 난수를 사용하고 테스트 조건이 바뀔 수 있기 때문이다. 운이 좋으면 금방 답이 나오고, 운이 나쁘면 만족스러운 답을 얻지 못할 수도 있다. 도시 10개 차이가 경로 수로는 10^16가지 만큼이나 차이가 난다. 아래는 DE가 구한 도시 간 최단 경로 그래프이다. site에 게시된 699보다 나은 최저 값을 쉽게 자주 찾아낸다. 이 경로가 최단 경로일까?

이제 DE로 풀어 보자. random population은 쉽게 만들 수 있다. 도시가 10개라면 10개 독립 변수가 있는 셈인데 도시에 0 ~ 9까지 id를 부여해서 도시 경로 vector를 만들어 주면 된다. 이 놈은 GA의 염색체와 동일하다. 단, 이 벡터 내에서 도시들은 방문 순서가 random하게 조합되어야 하고 한 번씩만 방문할 것이기 때문에 id가 중복돼선 안된다. 그냥 0~9까지 숫자를 채우고 random shuffling해 주면 한 개의 경로 벡터가 생성된다. population size가 30개라면 30개 경로 벡터를 이런 식으로 만들면 된다.  cost는 위의 그래프에서와 같이 연결된 두 도시 간의 거리(Euclidean distance)들을 모두 합산하면 된다.

GA의 경우엔 TSP에 도전한 사례가 많기 때문에 다양한 crossover operator들이 연구되었다. DE의 경우엔 위의 첫번째 식을 사용하면 된다. 두 경로 벡터 간의 차를 구해서 다른 경로 벡터에 더해주면 된다. 다만, 이 경우에 음수 값이 발생하게 되고, F 값을 곱해 주기 때문에 새로운 경로 벡터 생성시 실수를 정수로 바꿔주어야 하며, 결과 값인 도시 id가 중복되는 경우가 발생할 수 있다. 음수 값이 의미하는 것은 출발한 도시에서 반대 방향으로 가면 된다는 뜻이다. 이것은 Circular Queue에서 modulo index를 구하는 것과 같다. 도시 id가 중복되는 문제는 GA에서 crossover operator들이 널리 사용하는 방법처럼 중복이 발생하면 부모 벡터의 중복되지 않는 id들을 순서대로 채워 넣으면 된다. 경로의 도시 순서 조합을 최적화하는 것이기 때문에 합리적인 방법이다. 즉, 위의 첫째 식에서 기준점 벡터(X_r1)를 부모 벡터로 사용했다. 

이 후 과정은 population 내의 모든 경로 벡터에 대해 위의 과정을 반복하고 나서, GA에서와 같이 cost 값이 수렴할때까지 세대를 반복하면 된다. DE는 GA에 비해 상당히 작은 population을 사용하기 때문에 수렴이 잘되는 편이다. 하지만 이렇게 얻어진 값이 최적 해에 가까운지는 남들의 결과와 비교해보지 않고는 장담할 수 없다.

아래 그래프는 위의 dantzig42 경로 최적화 문제 해결에 DE가 몇세대를 허비하는지 보여 준다.  population size가 30개이므로 nfe(the number of function evaluations)는 2,000세대 x 30 = 60,000번 정도이다. local minima에서 벗어날 수 있도록 2%의 swap mutation을 적용했다. swap mutation은 두 유전자(도시)를 random하게 뽑아서 순서를 바꿔주는 방식이다.

그런데, DE를 테스트하면서 돌리다보니 맨 앞의 그래프보다 더 짧은 경로를 찾아내서 비교 목적으로 첨부한다. 이 놈을 발견하기 전에는 육안으로 쉽게 최적 해인지 알수 있을 거라 생각했었다. 아래 그래프가 dantzig42 최단 경로일까???


사실, 위의 결과까지는 binomial crossover 방식을 사용한 것인데 이 글을 쓰고 나서 다시 exponential crossover 방식을 사용해 보니 dantzig42 문제의 최단 거리는 679.202인 듯하다. 왼쪽 맨 꼭대기부터 오른쪽으로 4개의 점(3개의 선분 중 첫째와 셋째)을 달리 이으면 된다.

결론적으로, DE에서 F와 CR 뿐만 아니라, Np와 crossover 방식까지도 주어진 최적화 문제에 맞게 tuning이 필요하다. 이 중에서도 F와 CR은 더더욱 민감하다. 그래서 찾아보니 Self-adaptive DE에 대한 연구가 많았더라. 사실, 간단히 만들어서 테스트도 해 봤는데 그럭저럭 쓸만한 편이다. 말인 즉슨 모든 최적화 문제에 적용할 수 있는 Self-adaptive DE는 만들기 어렵다는 뜻이다. 

맺음말

TSPLIB 사이트의 문제들 중에서 그나마 내 PC에서 다양하게 테스트해 볼 수 있는 경우는 dantzig42까지이다. 변수 수가 적을 경우에는 DE가 매우 훌륭한 최적화 알고리즘이라 할 수 있다. 하지만, 변수 수가 늘어나면 복잡도가 지수적으로 증가하게 되기 때문에 DE가 좋은지 장담할 수 없다. berlin52를 (내 그닥 PC가) 밤새워 여러 번 돌려 본 결과는 좋지 않더라. 순수 GA의 경우 15만개 size의 population을 사용해서 24세대 만에 berlin52 문제를 해결했다더라. nfe로 따지면 대략 360만회 만에 해결한 셈인데 DE는 천만 번을 넘어도 좋은 해를 구하기 어렵더라.

요즘 Deep Learning에서 사용하는 parameter 수가 일억 개는 보통(?)이고 십억 개 이상을 넘보고  있단다. 겨우 42개 parameter가지고 깔작대봐야 머 큰 의미는 없다. 그렇다고 비관할 필요는 없다. PC로도 충분히 배울 수 있고 효율적인 알고리즘은 언제나 필요한 법이다. 사실 대기업에 준하는 회사가 아니고서 일억 개 변수를 사용해 볼 수 있는 서버를 보유한 회사가 그리 많지는 않을 것이다. 어쨌든 대자본이 인공지능을 점령하는 것은 시간 문제이다. 인공지능이 대자본을 점령하는 것도 시간 문제라 본다. 적자 생존 원리에 따라 기계가 적자가 될 수도 있을 것이다. 그것도 우주의 관점에서 보면 대수로운 일도 아니다. 누군가는 죽고 누군가는 살아 남는 일들은 늘상 우주에서 벌어지는 일이다. 코로나 바이러스에 걸려 죽든 교통 사고로 죽든 죽는게 중요한 것이 아니라, 오늘을 어떻게 살아 갈 것인가가 더 중요한 문제이다.


2020/12/20

유전 알고리즘을 이용한 함수 최적화

 

뜬금없이 먼가 떠올랐을때 해봐야 직성이 풀리는 못된 버릇이 있다. 세살 버릇 여든 간다. 오늘은 코로나 뉴스를 보다가, 소시적 원서를 질러 독학했던 유전 알고리즘(Genetic Algorithm; GA)이 문득 떠올랐다. 당시에도 여러가지 해보고 싶은게 많았지만 본류 주제가 아니었기에 접고 있었다. 그 중 하나가 실수(real number)를 쉽게 다루는 방법에 대한 것이다. 다변수 목적 함수(multi-variable objective function)의 최적화 문제를 풀기 위한 GA가 단변수 목적 함수(uni-variable objective function)에 대한 GA보다 오히려 단순하다. 다변수 함수에서는 변수 각각을 유전자(gene)로 간주하면 되지만, 단변수 함수에서는 유전자를 어떻게 encoding 할 지 고민해야 한다. 논문들을 보면 실수를 string으로 encoding해서 유전자로 사용했었다. 하지만, 당시에도 컴퓨터가 기본적으로 모든 숫자들을 binary로 encoding해서 사용하고 있는데 또 다시 encoding하는 것이 굉장히 불합리하다고 생각했다. 흠, 그래서 이 문제도 해결하고 보다 일반적으로 함수 최적화 문제를 풀 수 있는 GA를 만들어 보기로 했다.

GA는 매우 단순하면서도 효율적인 최적화 알고리즘이다. TSP(Travelling Salesman Problem: 영업사원이 n개의 도시를 순차적으로 방문 후 돌아오는 최단 경로 문제로 n! 갯수의 거리계산이 필요)와 같은 NP-hard한 조합 최적화 문제도 빠른 시간 안에 준 최적(suboptimal) 경로 조합을 찾아낼 수 있어서 많이 활용되기 시작했다. GA는 구현하기도 쉬워서 장난감으로 갖고 놀기에 좋은데, 장난감 치고는 더 대단한 일을 할 수 있는 놈이다. 목적 함수와 주요 독립 변수만 알고 있다면 대부분의 경우 전역 최적값(global optimum)에 근접한 해를 구할 수 있기 때문이다. 목적 함수에 대한 제약 조건(constraints)들이 있다면 더더욱 정확한 최적값을 얻을 수 있다. 이것은 과거의 전통적인 최적화 기법들과는 상반되는 것들이다. 즉, 함수의 연속성, 미분 가능성, 단극성(unimodality) 등 전통적인 최적화 기법들이 요구하는 제약사항들을 GA가 모두 극복할 수 있기 때문이다. 가령, 인공신경망을 학습시키려면 cost/loss function에 대한 미분 가능 여부가 걸림돌이 된다. GA 관점에서는 복잡한 인공신경망 목적 함수를 최소화하는 weights 값들을 구하는 문제로 귀결된다. 더구나 인공신경망에서 항상 문제가 되는 local minima에 대한 문제도 GA가 해결해 줄 수 있다. 사실, 이 부분도 소시적에 시도해 보고 싶었던 것 중의 하나였다. 인공신경망을 GA로 학습시키는 것 말이다. 혹시나 해서 구글링해 보니, 최근까지도 연구가 진행되고 있다. Reinforcement Learning에서도 학습 시키기 위해서 미분 가능하지 않은 목적 함수들을 채용할 수 있다는 점에서 매우 고무적이다. 인공신경망을 GA로 학습시킬 수 있다면 Deep Learning에도 굉장히 큰 변화를 가져올 것이다. 학습 시간을 줄이는 것은 물론이고 다양한 cost function을 사용할 수 있을 것이기 때문이다. 또한, real-time dynamic learning까지 가능할 수도 있다.

사실, 실생활에서 단변수 목적 함수를 사용할 일은 거의 없다. 흔히 사용되는 1차(1st order 또는 linear) 최소 제곱(자승)법(Least Squares Method/Regression)도 2변수 목적함수를 최적화하는 문제가 된다. 편미분을 이용해서 주어진 데이터를 가지고 최적의 직선 방정식(y = ax + b)을 구할 수 있다. 가령, 날짜별 코로나 신규 확진자 추이 데이터를 가장 잘 반영한 직선 방정식을 전통적인 최적화 기법으로 쉽게 구할 수 있다는 야그다. 하지만, 이 직선으로 내일 신규 확진자가 몇명일지 추정하기는 쉽지 않을 것이다. 시간별 확진자 수는 비선형적으로 증가하기 때문이다. 즉, 고차(higher order/non-linear) 목적 함수에 대한 최적화 문제로 귀결된다. 목적 함수가 비선형이 되는 순간 전통적인 최적화 기법들은 많은 제약이 뒤따르게 된다. 하지만, 단변수 목적 함수는 GA를 이해하고 알고리즘 자체를 개선하는데 매우 도움이 되기 때문에 단변수 함수 최적화에 GA를 사용하는 것 자체도 의미가 있다. 우리가 함수 그래프를 그려 볼 수 있는 것은 기껏해야 독립 변수가 2개까지인 경우이다. 독립 변수가 2개이면 3차원 그래프를 보아야 한다. 3차원 그래프도 유용하지만 2차원 그래프를 보면 훨씬 쉽게 함수를 이해할 수 있다. 이것이 단변수 함수 최적화가 갖는 의미다.

Genetic Algorithm(GA)

먼저, 알고리즘 자체를 간단히 살펴 보자. GA는 찰스 다윈의 자연선택설을 모방하여, random 염색체 군에서 선택(selection) - 교합(crossover) - 돌연변이(mutation)를 통해 새로운 세대의 염색체 군을 만드는 과정을 반복함으로써 세대가 지날수록 우량 염색체만 살아 남도록 하는 알고리즘이다. 자연의 진화 방식에 내재된 최적화 기법을 모사한 진화적 알고리즘(evolutionary algorithm) 중 하나이다. 이 때문에 GA를 흔히 meta-heuristic(반 경험적) 알고리즘이라 한다. 인공신경망과 유사하게 수학적으로 해석하기도 쉽지 않고 그렇다고 경험적으로 증명되었다고 치부해 버릴 수도 없다는 의미이다. 실제 GA 구현 방법은 차이가 날 수 있지만 원리는 동일하다. 아래는 GA의 대략적인 순서다. 

1. 유전자들(genes)을 염색체(chromosome)로 encoding

2. random 염색체들로 population 생성 및 fitness(우량도/적합도; 목적 함수 계산 값) 계산

3. fitness가 높은 순(또는 cost가 낮은 순)으로 염색체 정렬

4. 우량한 염색체들을 선택 교배(mate)시켜서 새로운 세대(generation) 생성

   4-1. elite 염색체 일부 복제하여 신세대 생성

   4-2. 상위 fitness 염색체들 간 random 교합(crossover) 후 신세대 생성 및 fitness 계산

   4-3. 돌연변이(mutation)로 신세대 생성 및 fitness 계산

5. 3 ~ 4의 과정을 사전에 정한 최대 세대 수 또는 종료 조건 만족시까지 반복

참고로, 대부분의 최적화 문제들은 cost를 최소화하는 것이기 때문에 GA의 fitness를 cost로 전환하여 사용하는 것이 좋다. fitness를 최대화하는 것은 cost를 최소화하는 것과 같다. 물론 목적 함수에 -1을 곱해서 사용해도 된다. 부가적으로 GA의 용어들을 최적화 용어로 대치하자면, 유전자는 목적 함수에 영향을 주는 요인이다. 다변수 함수라면 독립변수 각각에 해당된다. 염색체는 유전자들의 결합체이다. 교배시 필요한 하나의 개체이다. 다변수 함수에서는 최적 해의 후보 값 vector가 된다. 이 독립변수 vector 조합으로 목적 함수에 의해 fitness 값을 계산할 수 있게 된다.

GA에서는 난수(random number)들이 중요한 역할을 한다. 초기 염색체 population이 GA가 최적 해를 찾는데 큰 영향을 준다. 최적해의 후보를 탐색하는 관점에서 보면 가능한 모든 범위에서 균일한 난수가 생성되는게 좋다. 이 들이 대표 선수가 되어 좋은 유전자를 후대에 물려주게 된다. 하지만, GA 자체는 빠른 알고리즘이기 때문에 여러 번 돌려 보는게 더 나을 수도 있다. GA의 가장 큰 단점은 알고리즘 종료 조건을 설정하기 어렵다는 것이다. 처음엔 모두 다른 염색체들로 시작했는데 세대가 늘어날수록 우량 염색체가 다수를 차지하게 된다. 수렴하다가 더 좋은 값으로 바뀔 수도 있기 때문에 세대간 목적함수 값을 비교하기 어렵다. 목적함수 평균값에 대한 오차나 우량염색체 비율 등이 종료조건 후보가 될 수는 있지만 실제 돌려 보면 최대 세대수를 정해 놓고 돌리는게 더 나을 경우가 많다. 이것은 돌연변이를 사용해야 하기 때문에 발생하는 문제이다. 다른 최적화 알고리즘들과 마찬가지로 GA에 영향을 주는 매개 변수들이 있다. population 염색체 개체 수, crossover 확률, mutation 확률, 최대 세대 수, 탐색 구간 같은 것들이다. 좋은 방법은 처음엔 부정확하지만 빠르게 GA를 한번 돌려서 suboptimal 해를 구하고 나서 탐색 구간을 좁혀준 후 GA를 한번 더 돌리면 매우 정확한 값을 얻을 수 있다.

GA에서 실수형(real-number type) 단변수 및 다변수 사용

GA의 단변수는 64bit 실수를 사용하면 되므로 c++에서는 double type을 사용하면 된다. 앞서 얘기했듯이 컴퓨터는 이미 실수를 binary로 encoding해서 사용하고 있다. 우리는 이 놈이 염색체라고 보고 유전자들을 어떻게 선택 - 교합 - 돌연변이 시킬지를 알아내면 된다. 64bit encoding된 실수의 binary 형식을 알기 위해 Wikipedia를 참조할 필요가 있다.

아래는 교배(mate)함수를 구현한 것이다. size가 1일때가 Wikipedia를 참조해서 만든 단변수일 때 교배 방법이고 다변수에 대해서도 일관된 logic을 적용하였다. 너무나 간단한데 첨엔 이게 잘 돌아갈까 스스로 의심도 했지만 실제 GA를 돌려보니 단변수 함수의 전역 최적 해의 근사값을 너무도 잘 구해 내더라. 수치 해(numerical solution)로써 정확도 면에서도 손색이 없을 정도다. 참고로, Real64 type은 double과 uint64_t의 union type이다. 즉, 유전자 조작은 bit 연산으로 수행하고 fitness(목적 함수) 계산시에는 걍 double 값을 사용하겠다는 것이다. 좀 관심이 있는 사람들은 아래의 logic에서 몇가지 특이 사항을 발견할 수도 있을 것이다. 다변수의 경우엔 변수 갯수만큼 실수형 배열이나 아래와 같이 vector를 사용하면 된다.

  Chromosome mate(const Chromosome& another) {
    if(size == 1) {
      Real64 chro;
      for(int i = 52; i < 64; ++i) {
        float p = random_int(0, 100) / 100.0;
        if(p < (1 - pMutation) / 2) chro.i |= chromosome.i & (1 << i);
        else if(p < 1 - pMutation) chro.i |= another.chromosome.i & ( 1 << i);
        else chro.d = mutate();
        //else chro.i |= (chromosome.i ^ (1 << i)) & (1 << i);
      }
      //unsigned nan = (chro.i >> 52);
      //if(nan == 0x7ff || nan == 0xfff) chro.i ^= (1ul << 52); // NaN or INF
      return Chromosome(chro);
    }
    else {
      std::vector<double> gv;
      for(int i = 0; i < size; ++i) {
        float p = random_int(0, 100) / 100.0;
        if(p < (1 - pMutation) / 2) gv.push_back(xv[i]);
        else if(p < 1 - pMutation) gv.push_back(another.xv[i]);
        else gv.push_back(mutated_gene(i));
      }
      return Chromosome(gv);
    }
  }

전체 알고리즘을 구현하는 것은 별로 어렵지 않기 때문에 관심이 있는 사람들은 직접 구현해 보길 권한다. 흠, 나는 자주 바퀴를 다시 발명하기를 권하는데 일반적으로는 권장하지 않는 가이드이다. 하지만 고집하는 이유는, 자신이 만든 것에 사람들은 애착을 느끼게 되고 이것이 삶이나 행동에 큰 변화를 줄 수 있다는 믿음 때문이다. 더구나 직접 만들어 보면 남의 것을 사용하던 때와 굉장히 다르다는 것을 깨닫게 된다. 기존의 바퀴에 개선의 여지가 여전히 많다는 것도 자연스럽게 깨칠 수 있다. 이런 것은 누가 가르쳐 줄 수도 없는 것들이다. 

GA 테스트 결과

아래 그래프는 단변수 목적 함수인 y = (x^4 + 2*x^3 - 3*x^2 + 13)^2 - 5를 구글링해서 Firefox로 capture 한 것이다. 흠, 우연히 그래프를 우클릭했는데 capture 기능을 발견해서 당장 써먹는다.

이 함수의 전역 최소값은 아래 그래프에서 GA가 72번째 세대에서 수렴한 [x', Cost'] = (-2.188332, -4.63122) 값과 거의 일치함을 알 수 있다.

이 함수말고도 주기 함수나 불연속 함수 등 몇가지 단변수 함수의 전역 최소값 구하기를 GA로 돌려 보았는데 너무도 잘 찾을뿐 아니라 값들도 상당히 정확하다. 주기 함수의 경우 범위에 대한 constraint를 주면 더 정확한 x 값을 찾아낸다.

독립변수가 2개인 경우까지는 그래프 구글링을 통해 대략 최적값이 맞는지 확인할 수 있다. 아래는 Rosenbrock 함수로 알려진 z = 100*(x^2-y)^2 + (1-y)^2 함수를 구글링한 것이다. 전역 최소값 [x', y', z'] = (1, 1, 0) 또는 (-1, 1, 0)이다. 이 놈은 local minima에 빠져서 global 최소값을 찾기 어려운 놈으로 테스트하기 좋은 2변수 함수이다. 구글링해보면 이놈을 어떻게 보는게 좋을지도 고민하게 되는 상당히 난감한 놈이다. 이 놈을 GA로 돌려 보면 초기 난수 분포에 따라 최종 결과가 달라지긴 하지만 전역 최소값에 근사한 값들을 어려움없이 잘 찾아낸다.

간단한 데이터를 가지고 GA로 linear regression을 해 보니 대체로 잘 된다. 변수 범위를 지정하면 편미분으로 구한 정확한 값과 거의 일치하더라. 구글링해 보니 고차(higer-order) regression 문제도 GA로 해결한 사례들이 꽤 있더라.

3변수 이상 다변수 목적함수인 경우에도 GA를 많이들 써먹고 있다. GA 테스트는 할 수 있지만 결과가 맞는지는 남들이 구한 최적 값과 비교해야 하는 난감함이 따른다. 구글링해서 한가지 돌려 봤는데 잘 맞더라. 흠, 아무튼 이제 Deep Learning에 GA를 테스트할 일이 남았는데 언제쯤 할지는... Deep Learning 분야는 상대적으로 학습이 잘됐는지 확인할 수 있는 방법들이 있어서 오히려 편할 수 있다. 

참고로, 3변수 이내에서는 population 크기를 1000으로 하고 1000세대까지 웬만한 PC에서 돌려도 거의 1분 내에 답을 찾을 것이다.


2020/10/04

c++20 Coroutine 배우기


올해 말까지는 c++20 표준이 공표될거라 해서 Big 4라 불리는 놈 중 하나인 Coroutine에 대해 배워 보기로 했다. 이 놈에게 관심을 가진 이유는 비동기 처리를 쉽게 할 수 있어 보여서다. 이를 테면 Signal & Slots를 callback을 사용하지 않고 구현할 수도 있을까 하는 궁금증... 

결론적으로 모든 것을 대체할 수는 없다는 생각에 이르렀지만 활용 범위는 꽤 늘어날 듯 싶다. 당장 게임 같은 곳에서는 UI와 로직을 분리할 수 있어서 꽤나 유용할 듯 싶다. 이외에도 이벤트 처리, generator, lazy-evaluation 알고리즘, 네트워크 등의 비동기 I/O에 활용될 수 있다. Asio 네트워크 라이브러리에서는 10년 전부터 매크로를 이용해서 c++20의 코루틴 구현 방식인 stackless 코루틴을 사용해왔단다. 세상엔 대단한 넘들이 많다. c++20의 코루틴은 pointer를 처음 배우는 것만큼 배우기도 쉽지 않다. 뭐, 알고 나면 별거 아닌게 되지만...

코루틴에 대해 제대로 정리하려면 너무 길어지므로, 이 글은 간단한 예 하나 만들어 본 것을 정리하는데 그친다. c++20의 코루틴이 복잡한 이유는 컴파일러가 생성해 주는 일종의 coroutine framework에 맞추어서 코딩해야 하기 때문이다. 숨겨진 로직이 많다는 것이다. 나중에 cppcoro와 같은 라이브러리가 표준으로 채택되면 사용자들은 동작 방식보다는 활용 방법만 배우면 될 것이다.   

코루틴 이해

Coroutine은 원래 main 루틴이 아닌  모든 subroutine을 포함하는 개념이란다. 좁은 의미로는 진입 지점을 여러 개 가질 수 있는 함수가 코루틴이다. 기존 함수는 시작(by-call)과 끝(return)만 있는데, 중단(suspend)했다가 다시 재개(resume)할 수 있는 기능이 추가된 함수로 이해하면 된다. suspend/resume이 필요한 이유는 실행 흐름을 비동기(asynchronous) 방식으로 제어하기 위해서이다. 결국, 코루틴은 실행 흐름을 논리적으로 제어하기 위한 함수이다. 좀 깊이 들어 가면 선점형(pre-emptive) 스케쥴링과 협력적(cooperative) 스케쥴링에 의한 multi-tasking 개념을 이해해야 한다. Thread와는 독립적인 개념인데, 기본적으로는 하나의 thread로 멀티태스킹을 할수 있다고 보면 된다. 비동기 네트워크 서버가 단일 쓰레드로 다수의 클라이언트를 처리하는 것을 떠올리면 된다.

3가지 키워드인 co_await, co_yield, co_return 중 하나라도 사용된 함수는 코루틴이 된다. main() 함수는 코루틴이 아니므로 3가지 키워드를 직접 사용할 수 없다. 코루틴 framework은 Promise Interface, Awaitable Interface, Coroutine Handle의 3가지 API를 이해해야 한다.  앞의 두 인터페이스는 사용자가 작성해야 한다. Handle은 컴파일러 내장 함수이고 <coroutine> 표준헤더를 참고하면 된다. Promise Interface는 코루틴 객체 class 내에 정의하여 코루틴의 동작 방식을 제어한다. Awaitable Interface는 co_await 키워드가 어떻게 동작할 지 정의하기 위한 것이다.  Handle은 코루틴의 재개(resume)와 소멸(destroy), 현재 상태(done), 메모리상의 위치(address) 등에 대한 인터페이스이다. 3가지 키워드는 3가지 API를 적절하게 사용하기 위해 필요한 것이다.

코루틴 사용 예

class SimpleTask
{
public:
  struct promise_type;
  using coro_handle = coroutine_handle<promise_type>;
  struct promise_type {
    auto get_return_object() { return SimpleTask{coro_handle::from_promise(*this)}; }
    auto initial_suspend() { return suspend_never{}; }
    auto return_void() { return suspend_never{}; }
    auto final_suspend() { return suspend_always{}; }
    void unhandled_exception() { std::terminate(); }
  };

  SimpleTask(SimpleTask&& r) = default; // ensure copy elision
  ~SimpleTask() { if(m_handle) m_handle.destroy(); }
  bool resume() { if(!m_handle.done()) m_handle.resume(); return !m_handle.done(); }

private:
  explicit SimpleTask(coro_handle handle) : m_handle(handle) {}
  coro_handle m_handle;
};

// For AsyncTimer, the total time required for coro_simple() should be 3 seconds???
// If you think so, try to make it~!!!
SimpleTask coro_simple()
{
  std::cout << "Hello\n";
  co_await 3s;
  std::cout << "  after 3 sec ...\n";
  co_await 2s;
  std::cout << "  after 5 sec ...\n";
  co_await 1s;
  std::cout << "  after 6 sec ...\n";
  std::cout << "Coroutine~!\n";
}

int main()
{
  std::cout << "+++ Start\n";
  SimpleTask task = coro_simple();
  while(task.resume());
  std::cout << "--- End\n";
}
위의 소스에서 coro_simple() 함수에 co_await 키워드가 사용됐으므로 코루틴이다. 코루틴 반환 객체(간단히, 코루틴 객체)인 SimpleTask class내에 정의된 promise_type이 Promise Interface이다. 일반 함수와 달리 SimpleTask를 return하는 부분이 안보이지만 컴파일이 잘 된다. 실제로 SimpleTask객체를 promise.get_return_object() 인터페이스 함수를 통해 코루틴 최초 중단 시와 완료 시 내부에서 반환하기 때문이다. co_await 키워드만 사용했지만 함수 맨 끝에 co_return이 있는 것과 같다. 일반 void 함수 끝에 return을 사용하지 않아도 되는 것과 유사하다. 사실, SimpleTask class는 미래엔 라이브러리로 제공될 부분이다. 사용자는 그것들을 이용하는 방법만 알면 된다.
c++은 너무 유연해서 코루틴도 일반 함수처럼 동작할 수 있지만, 기본적으로 코루틴은 한 번 이상 중단/재개가 일어난다고 보면 된다.  코루틴이 중단 된다고 프로그램이 중단되는 것이 아니다. 중단하는 이유는 원하는 결과를 기다리기 위한 것이다. 코루틴이 비동기적이라고 하는 이유이다. 가령 위의 co_await 3s; 부분은 코루틴을 중단하고 3초간 기다리라는 것이다. 중단되면 실행흐름이 caller인 main() 함수로 돌아간다. while 문에서 코루틴을 재개해서 중단된 지점에서 다시 시작하도록 한다.

Promise Interface

Promise Interface는 promise_type을 통해 코루틴 실행시 중단 및 재개 지점에서 호출되는 interface method 들을 정의하고 코루틴 자체의 동작 방식을 제어한다. std::future<>와 함께 쓰이는 std::promise<>와 유사하지만 좀더 확장된 개념으로 보면 된다. promise_type 객체는 "약속"이라는 이름과 같이 비동기 함수 호출시 어떤 상태 값을 넘기기로 사전에 약속한 객체라고 이해하면 될듯하다.
즉, 컴파일러가 코루틴 키워드 하나를 만나면 Promise Interface를 통해 아래의 pseudo-code와 같은 전체 코루틴 골격을 만들어 준다. 사용자가 작성한 코루틴 코드 전후로 뭔가를 co_await 하고 있다.
코루틴 함수가 호출되면 필요시 Coroutine Frame을 heap에 allocation 하고 promise_type 객체를 생성하는 등 컴파일러가 해주는 일이 많은데 아래 소스의 링크를 참고하자. 참고로 Coroutine Handle은 Coroutine Frame에 대한 pointer이다. 아래의 로직에서 initial/final suspend() 함수 내에서 코루틴이 중단/재개 될 수 있다고 보면 된다.
// Psuedo-code for Coroutine
// - source: https://lewissbaker.github.io/2018/09/05/understanding-the-promise-type

{
  co_await promise.initial_suspend();  // 사용자 코드 실행 전 뭔가 기다린다.
  try
  {
    <body-statements>                  // 사용자 코드(coro_simple() body) 실행
  }
  catch (...)
  {
    promise.unhandled_exception();     // 예외 처리
  }
  
FinalSuspend:
  co_await promise.final_suspend();    // 사용자 코드 실행 후에도 뭔가 기다린다. 
}

Awaitable Interface

이제 Awaitable Interface도 마저 예를 통해 보자. 3가지 키워드 중 co_await는 overloading이 가능한 단항 연산자(unary operator)이다. 위의 코루틴 pseudo-code에서도 co_await가 뭔가 중요한 일을 하고 있다. Awaitable은 co_await 연산을 적용할 수 있는 c++20 Concept(특정 조건들을 충족하는) type이다. Awaiter type은 Awaitable type의 3가지 Interface 함수를 구현한 Concept type이다. 아래 소스에 3가지 함수가 나타나 있다. 이들은 코루틴이 중단 후 caller에게 돌아갈 지 아니면 코루틴을 재개(resume)할지 제어하기 위한 것이다.

참고로, 아래의 소스는 co_await 연산자 overloading을 통해 시간이 입력값인 경우 AsyncTimer로 동작하도록 하기 위한 것이다. 생각보다 AsyncTimer가 코루틴에서 제대로 동작하도록 만들기 어렵다는 것을 알았다. 그래도 나름 간단하게 사용할 수 있어서 편한 점도 있다. 보통은 코루틴 객체 class 내부에서 co_await 연산자를 오버로딩함으로써 해당 객체로 부터 Awaiter 객체를 얻어내면 되는데 시간은 코루틴 객체가 아니지만 co_await 할 수 있어야 하기 때문에 전역 co_await 연산자를 오버로딩한 것이다.
// Overloaded co_await operator for time duration - AsyncTimer. (e.g. co_await 3s;)
// Do you have a better way for AsyncTimer to be used along with coroutine?
// A thread for timer can be used, but if the coroutine is resumed all the left code resumed
//   before the timer expires - how to suspend again and return to the caller?
template <typename TR, typename TP>
inline auto operator co_await(const std::chrono::duration<TR, TP>& duration) noexcept
{
  struct TAwaiter {
    using TClock = std::chrono::high_resolution_clock;
    using TTime = decltype(TClock::now());
    std::chrono::duration<TR, TP> duration;
    TTime start;
    TAwaiter(const std::chrono::duration<TR, TP>& d) : duration(d), start(TClock::now()) {}
    bool await_ready() noexcept { return duration.count() < 1; } // for 0 or negative time
    auto await_suspend(coroutine_handle<>) noexcept {} // just suspend and return to caller
    auto await_resume() noexcept {                     // sleep for left time when resumed
      if(duration.count() < 1) return;
      auto te = std::chrono::duration_cast<std::chrono::microseconds>(TClock::now() - start);
      std::this_thread::sleep_for(duration - te);
    }
  };
  return TAwaiter{duration};
}
co_await <expr> 구문은 <expr>을 evaluation 한 후, Awaiter 객체를 얻어 아래의 pseudo-code에 보인 Awaitable Interface 로직에 따라 제어 흐름이 동작하도록 한다. 가령, 아래는 promise.initial_suspend()를 실행한 결과 값에 co_await 연산을 적용해서 Awaiter 객체를 얻은 후, 아래의 pusedo-code 로직에 따라 결과 값을 얻는다. 결과 값/type은 await_resume()이 반환하는 값/type이다.
co_await promise.initial_suspend(); 
앞의 SimpleTask class에서 이 함수는 suspend_never 객체를 return하고 있다. 이 놈은 suspend_always와 함께 <coroutine> 헤더에 정의된 가장 기본적인 Awaitable type이다. 아래의 pseudo-code 로직을 적용하면, 
  • co_await suspend_never{};   => 코루틴을 중단시키지 않고 즉시 재개
  • co_await suspend_always{}; => 코루틴을 중단시키고 caller에게 제어 흐름을 넘김
이라는 뜻이 된다.

마지막 키워드인 co_yield <expr>의 의미는 아래와 똑같다.
co_await promise.yield_value(<expr>);
yield_value(<expr>) 함수는 co_return <expr> 키워드가 사용하는  return_value(<expr>) 또는 return_void() 대신 사용해야 하는 Promise Interface 함수이다.
// Psuedo-code for "co_await <expr>;" statement.
// - source: https://blog.panicsoftware.com/co_awaiting-coroutines

Awaiter&& awaiter = get_awaiter(promise, <expr>);
if (!awaiter.await_ready()) {
  <suspend-coroutine>              // suspend 상태에서만 코루틴 resume() 및 destroy() 가능
  try { // for a <result type> of awaiter.await_suspend(coroutine_handle)
    if constexpr <void type> 
      awaiter.await_suspend(coroutine_handle);
    if constexpr <bool type> {
      bool await_suspend_result = awaiter.await_suspend(coroutine_handle);
      if(!await_suspend_result) goto <resume-point>;
    }
    if constexpr <coroutine_handle type> { // tail-call optimization 적용됨
      another_coro_handle = awaiter.await_suspend(coroutine_handle<promise_type> h);
      another_coro_handle.resume();
    }
    <return-to-caller-or-resumer>  // 여기에 닿지 않고 코루틴이 완료되면 handle이 자동 소멸됨
  } catch (...) {
    std::exception_ptr exception = std::current_exception();
    if(exception) std::rethrow_exception(exception);
  }
}

<resume-point> :
return awaiter.await_resume();     // co_await type을 여기서 결정
위의 로직을 컴파일러가 노래로 알려 주었다.

< 함께 기다려 (co_await something) > - c++20 컴파일러가 인간들에게.

무언가 함께 기다리는 것은              무언가 함께 기다리는 것은
그것을 핑계로                                 가슴이 아파도
잠깐 쉬게 하려는 것.                        홀로 보내야만 하는 것.

준비된 이에게는                              기약없이 떠나지만
선물 주어                                        언젠가는 함께
기꺼이 다시 시작하도록,                  기꺼이 다시 시작하도록,

아니면                                            시련은
잠시 그 자리에 멈춰 세워,                거세지만 내 의지로 맞서,

마음을 비우거나 진실한 이는            어둡고 거친 길에 지치고 외로워도  
사랑하는 이에 돌아 가도록,              잠시나마 쉴 수 있도록,
새 꿈을 가진 이엔                             끝없이 펼쳐진 갈래길을 헤매어도  
희망 찾아 떠나 가도록,                    가다 보면 만날 수 있도록,
거짓된 이도                                     또 다시는
선물 주어 다시 시작하도록.              그 누구도 기다리지 않도록.

2020/09/14

c++ Type Erasure 활용

 

이전 글에서 만든 표준 c++ 기반의 Slot Delegate를 이용해서 예전에 만들었던 Signal 부분도 표준 c++로 바꾸려다 보니, 또 다른 종류의 Type Erasure를 사용하면 쉽게 해결되는 부분을 발견하였다. 그것은 Slot이 shared_ptr<>인 경우 Signal container에서 자동으로 life-cycle을 tracking 해서 dangling pointer를 없애주는 부분이다.

이 놈도 Delegate 만큼이나 흥미로운 놈이다. c++17의 std::any와 비슷하지만 사용법이 다르다. 옛날 옛적엔 서로 다른 type의 객체를 컨테이너에 가두고 뺑뺑이 돌리기 위해 void*를 사용했었는데, 이제는 이 놈을 사용하는게 좋겠다. 아래에 std::shared_ptr<>를 가지고 AnySharedPtr 라는 Type Erasure를 구현한 예를 보였다. 사용하기는 편하지만, 이 놈의 단점은 객체를 저장하기 위해 memory allocation이 필요하고 이에 따라 성능에도 악영향을 준다. 아무튼 Signal에서 shared_ptr<>가 reset 되거나 scope을 벗어나는 경우, p.get()과 p.use_count() 정보 만으로도 해당 Slot을 자동 제거해 줄 수 있다. 

이 놈이 재미있는 것은 Delegate 처럼 상속을 사용하지 않는다(내부에서만 사용함). std::function<> 류의 Delegate은 함수형의 any callables를 대신하기 위해 사용되는데 비해, 이 놈은 std::any와 같이 any 객체에 사용될 수 있다. 하지만, std::any와는 달리 std::any_cast<>를 사용할 필요가 없다. 돌려서 말하면, Delegate와 마찬가지로 type이 지워져서 객체 type을 정확히 알아야 하는 경우엔 사용할 수가 없다. 

또한, Visitor Pattern을 사용하지 않고도 공통 interface만 있으면 서로 다른 유형의 객체를 std::vector<>와 같은 컨테이너에 담아서 반복 작업을 돌릴 수 있다. 아래의 예를 보는게 이해가 빠를 것이다.


#include <memory>

// Type Erasure for any type of std::shared_ptr<>s.
//
// - typical usage: save any types to a container and iterate some common works.
//   std::vector<AnysharedPtr> shared_pointers;

class AnySharedPtr
{
  class Concept
  {
  public:
    auto clone() const { return std::unique_ptr<Concept>(clone_impl()); }

    // Interface methods for Any types. (e.g. std::shared_ptr<> here)
    // - the return type is limited to basic POD types: the original type is erased.
    virtual void* get() const = 0; // NB: the original pointer type is erased.
    virtual long use_count() const = 0;

  protected:
    // Virtual constructor idiom
    virtual Concept* clone_impl() const = 0; // virtual copy constructor - base return
  };

  template <typename TM>
  class Model : public Concept
  {
  public:
    Model(TM&& obj) : m_model(std::forward<TM>(obj)) {}

  protected:
    // Virtual constructor idiom
    Model* clone_impl() const override { return new Model(*this); } // covariant return

    // Interface methods for Any types. (e.g. std::shared_ptr<> here)
    void* get() const override { return m_model.get(); }
    long use_count() const override { return m_model.use_count(); }

  private:
    TM m_model;
  };

public:
  // The rule of five is applied to use std::unique_ptr<> as a member variable.
  AnySharedPtr() = default;
  AnySharedPtr(const AnySharedPtr& rhs) : m_concept(rhs.m_concept->clone()) {}
  // Non-const constructor is required here to prevent the perfect forwarding constuctor.
  AnySharedPtr(AnySharedPtr &rhs) : AnySharedPtr(static_cast<const AnySharedPtr&>(rhs)) {}
  AnySharedPtr(AnySharedPtr&&) = default;
  template <typename TM> // perfect forwarding constructor
  AnySharedPtr(TM&& obj) : m_concept(std::make_unique<Model<TM>>(std::forward<TM>(obj))) {}
  ~AnySharedPtr() = default;

  AnySharedPtr& operator=(const AnySharedPtr& rhs)
  { m_concept = rhs.m_concept->clone(); return *this; }
  AnySharedPtr& operator=(AnySharedPtr& rhs)
  { return operator=(static_cast<const AnySharedPtr&>(rhs)); }
  AnySharedPtr& operator=(AnySharedPtr&&) = default;
  template <typename TM> // perfect forwarding assignment operator
  AnySharedPtr& operator=(TM&& obj)
  { m_concept = std::make_unique<Model<TM>>(std::forward<TM>(obj)); return *this; }

  // Interface methods for Any types. (e.g. std::shared_ptr<> here)
  void* get() const { return m_concept->get(); }
  long use_count() const { return m_concept->use_count(); }

private:
  std::unique_ptr<Concept> m_concept; // storage for type erasure
};

int main()
{
  std::shared_ptr<int> si = std::make_shared<int>(10), si_copy1 = si, si_copy2 = si_copy1;
  std::shared_ptr<std::string> ss = std::make_shared<std::string>("hello"), ss_copy = ss;
  
  std::vector<AnySharedPtr> any_ptrs;
  any_ptrs.push_back(si); any_ptrs.push_back(ss);
  for(auto p : any_ptrs) { std::cout << "Ptr: " << p.get() << ", " << p.use_count() << '\n'; }
  
  return 0;
}


2020/08/30

Slot - Delegate의 재 발명


Signals and Slots를 구현해 본지 2년이 됐는데 문득 아쉬웠던 부분 들이 떠올라서 심심삼아 바퀴를 다시 발명해 보기로 했다. Signals and Slots는 Multicast Delegate와 비슷한 개념이다. Delegate의 특별한 응용 분야라고 생각할 수도 있다. 그래서, 일단 Delegate 구현시 부족했던 부분을 다시 채워 보기로 했다.

이전 버전에서는 Slot이 독립적인 Delegate 역할을 하는데는 한계가 있었다. 가장 찜찜했던 부분은 역시나 표준 c++을 따르지 않는 것이었다. 문제는 표준 c++에서 멤버 함수의 pointer를 직접 저장할 수 있는 방법이 없다는 것이다. Template을 이용해서 멤버 함수를 컴파일러가 바인딩하도록 해주면 실행 속도도 빨라지고 문제가 해결되긴 하지만 Delegate 사용시 interface가 매우 불편하다. 궁극적으로 Qt의 Signals and Slots 스타일로 사용할 수 없다. 사실, reinterpret_cast<>나 union을 이용해서 type-punning(억지로 형 바꾸기)한 변수들을 사용하는 부분은 모두 표준 c++에 위배된다. 억지로 바꾸려다 형 한테 맞는다???

흠, 혹시나해서 구글링해 보니 그 간에도 수 많은 넘들이 자신 만의 Delegate을 발명하고 있더라. Delegate가 흥미로운 놈인건 사실이다. 특히나 c++을 배우고 있다면 한번 쯤 자신의 바퀴를 발명해 보기 바란다. c++ 언어 자체가 계속 버전업 되다 보니 새로운 방식으로 도전해 보는 이들도 있다. 이를 테면 c++20의 concept을 활용해 볼 수도 있겠다. 여기서는 너무 멀리는 가지 않고 c++17의 if constexpr와 std::invocable을 이용해서 코드를 단순화 시켰다. 

근본적인 문제를 해결했는데, c++ 표준에서 함수/멤버 함수 pointer는 void* 포인터로 저장할 수 없지만, object instance는 void* 포인터로 저장할 수 있다는 점을 이용한 것이다. void*는 객체를 저장하기 위한 포인터이기 때문에, 비객체 포인터를 void*에 강제 할당하는 것은 표준 c++에서 벗어난다. 여기를 보면 c++ 객체에 대해 올바르게 이해할 수 있다. 또, 억지로 형 바꾸기를 하면 안되는 이유도 알 수 있다.  즉, 함수/멤버 함수 포인터를 객체 class로 감싸주면 void*에 저장할 수 있게 된다.

void*에 데이터를 저장하게 되면 객체들의 type을 모두 잃어 버리게 되는데, template type deduction을 이용해서 type을 복원해 주어야 한다. 형을 지웠다가 다시 쓸 수 있게 하는 넘들을 Type Erasure(형 지우개)라 하더라. Delegate Pattern에서는 inheritance를 사용하는데 다중 상속시 발생하는 문제와  virtual table 참조에 의한 성능 문제 때문에 자신 만의 Delegate를 만드는 넘들이 생겨났다. 

이렇게 만들어진 Delegate는 실상 std::function<>을 다시 발명한 것이다. 원래 std::function<>이 표준 Delegate인 셈이다. 하지만, std::function<>은 상당히 무겁고 느린 편이다. 더구나 안전을 보장하기 위해 모든 Callable 객체를 복사해 두기 때문에, Callable 객체들을 비교해야 한다면 추가적인 성능 부담이 생길 수 밖에 없는 구조다. 여기를 보니까 std::function<>을 다시 발명하고자 할 때 고려해야 할 점들을 잘 정리했더라. lambda도 Delegate의 역할을 일정부분 수행할 수는 있지만 Signals & Slots를 포함한 다양한 분야에 적용하는데 한계가 있다.

결론적으로, 아래와 같이  Slot이라는 나만의 Delegate를 다시 만들었다. SBO(Small Buffer Optimization) 라든가 Placement new라든가 하는 소소한 기법들이 들어가 있다. 사용법은 std::function<>과 거의 동일하고, 멤버 함수의 경우 std::bind<>를 사용하지 않고도 바로 초기화해서 사용할 수 있다. 

재미삼아 만들긴 했지만 이전 버전과 비교해서 여러가지 감안해도 두배 이상 소스가 늘어났다. 표준을 따르는게 얼마나 고된 일인가? 그니까 표준을 잘 만들어라~!!!

// Slot (Delegate or Callback) C++ Implementation
//
// This program is copyright (c) 2020 by Umundu @ https://zapary.blogspot.com .
// It is distributed under the terms of the GNU LGPL version 3, as detailed in
// https://opensource.org/licenses/lgpl-3.0.html .
//
// There are so many c++ Delegate implementaions. -  What's the point in this Slot?
// => Make simple interface without losing performance while keeping c++ standard.
//
//  o Simple usage can be extended to Signals and Slots(Qt) style interface: e.g)
//    Slot<double(int)> s1, s2(&obj, &Derived::doWork), s3([&c](int i){ return c+i; }), s4;
//    s1 = lambda; s4 = free_function; double result = s3(100);
//  o Comparison of two Slots is based on the IDs that are created from the source objects.
//  o Can be used as a light weight version of std::function<>.
//
//  * Compiler requirements: c++17 - supporting c++11 could be handy by replacing two parts:
//    - std::is_invocable_r<> => can be replaced to std::is_convertible<> things.
//    - if constexpr()        => can be replaced by using the SNIFAE.

#include <functional>
#include <memory>

template <typename T> class Slot;
template <typename TR, typename... TAs>
class Slot<TR(TAs...)>
{
  static constexpr size_t BufferMaxSize = 32;
  using TypeID = size_t;
  using TypePF = TR(*)(TAs...);
  using TypeCallback = TR(*)(void*, TAs&&...);
  using TypeCleaner = void(*)(void*);
  using TypeOPF = struct { TypePF pf; };

  // All the callables should be std::is_invocable_r<TR, TO, TAs...> OK.
  // Non-static member function is the most special citizen among the callables.
  // Comparing with the type of a function pointer(TypePF):
  //   Callables =>  |free fn|static member fn|lambda w/o capture|functor & lambda w/ capture
  //   is_same       |  yes  |      yes       |       no         |     no
  //   is_assignable |  yes  |      yes       |       yes        |     no
  template <typename T>
  using TypeIfFunction = typename std::enable_if<std::is_assignable<TypePF&, T>{}>::type;
  template <typename T>
  using TypeIfFunctor = typename std::enable_if<!std::is_assignable<TypePF&, T>{}
    && !std::is_same<std::decay<Slot>::type, std::decay<T>::type>{} // use default ctors.
    && std::is_invocable_r<TR, T, TAs...>{}>::type; // since c++17.

public:
  // Use default constructors for Slot according to the rule of zero.
  Slot() = default;
  Slot(const std::nullptr_t) noexcept : Slot() {};

  // for free functions, lambdas without capture and static member functions.
  template <typename TF, typename = TypeIfFunction<TF>>
  explicit Slot(TF pf) noexcept { bind(pf); }

  // for functors including lambdas with capture and std::function<>.
  template <typename TF, typename = TypeIfFunctor<TF>>
  Slot(TF&& pobj) noexcept { bind(std::forward<TF>(pobj)); }

  // for static member functions
  template <typename TB>
  explicit Slot(TB*, TypePF pmf) noexcept { bind(pmf); }

  // for non-static member functions
  template <typename TB, typename TO>
  Slot(TO* pobj, TR(TB::*pmf)(TAs...)) noexcept { bind(pobj, pmf); }
  template <typename TB, typename TO>
  Slot(const TO* pobj, TR(TB::*pmf)(TAs...) const) noexcept { bind(pobj, pmf); }

  Slot& operator=(TypePF pf) { bind(pf); return *this; }
  template <typename TF, typename = TypeIfFunctor<TF>>
  Slot& operator=(TF&& fn) noexcept { bind(std::forward<TF>(fn)); return *this; }

  explicit operator bool() const { return m_obj; }
  bool operator==(const Slot& rhs) const { return m_id == rhs.m_id; }
  bool operator!=(const Slot& rhs) const { return !operator==(rhs); }
  bool operator==(const std::nullptr_t) const { return !m_obj; }
  bool operator!=(const std::nullptr_t) const { return m_obj; }

  TypeID id() const { return m_id; }
  TR operator()(TAs&&... args) const noexcept { return emit(std::forward<TAs>(args)...); }
  TR emit(TAs&&... args) const noexcept
  {
    if(!m_obj) return TR();
    if(m_callback) return m_callback(m_obj, std::forward<TAs>(args)...);
    return (*static_cast<TypeOPF*>(m_obj)->pf) (std::forward<TAs>(args)...);
  }

protected:
  void bind(TypePF pf) noexcept
  {
    m_id = hash(reinterpret_cast<void*>(pf), nullptr);
    // Using storage for function pointer types is a design choice(cf. using template).
    // NB: type-punning by reinterpret_cast<> for a function pointer violates the c++ standard.
    store<TypeOPF>(std::move(TypeOPF{pf}));
  }

  template <typename TF, typename = TypeIfFunctor<TF>>
  void bind(TF&& fn) noexcept
  {
    using typeF = typename std::decay<TF>::type;
    // NB: any valid objects can be referenced to the generic pointer(void*).
    //   - functions, member functions and references are not objects.
    //   - so their pointers can't be converted to the generic pointer.
    m_obj = &fn;
    auto pmf = &typeF::operator();
    m_id = hash(m_obj, reinterpret_cast<void*>(reinterpret_cast<void(*&)()>(pmf)));
    m_callback = callFunctor<typeF>;
    // Storage is required for RValue lambdas with capture.
    // LValue functors including lambdas with capture can be called directly.
    if constexpr(!std::is_reference<TF>{}) store<typeF>(std::move(fn)); // since c++17.
  }

  template <typename TB, typename TPMF>
  void bind(TB&& pobj, TPMF&& pmf) noexcept
  {
    using typeTB = typename std::decay<TB>::type;
    using typeMF = struct { typeTB obj; typename std::decay<TPMF>::type pmf; };
    // Somthing weird but possible way.
    m_id = hash(pobj, reinterpret_cast<void*>(reinterpret_cast<void(*&)()>(pmf)));
    m_callback = callMember<typeMF>;
    // Using storage for non-static member functions is a design choice.
    // This is trade-off in using interfaces between fast but inconvenient template style
    // versus rather slow but more convenient std::function<> style.
    // NB: type-punning by reinterpret_cast<> for pointer to member functions violates
    //     the c++ standard.
    store<typeMF>(std::move(typeMF{std::forward<TB>(pobj), std::forward<TPMF>(pmf)}));
  }

private:
  size_t hash(const void* obj, const void* pmf) const
  { return reinterpret_cast<size_t>(obj) ^ reinterpret_cast<size_t>(pmf); }

  template <typename TF>
  static TR callFunctor(void* vobj, TAs&&... args) noexcept  // Why static?
  { return (static_cast<TF*>(vobj)->operator())(std::forward<TAs>(args)...); }

  template <typename TP>
  static TR callMember(void* vobj, TAs&&... args) noexcept   // Why static? => Your homework!
  {
    TP* pobj = static_cast<TP*>(vobj);
    return (pobj->obj->*pobj->pmf)(std::forward<TAs>(args)...);
  }

  template <typename TF>
  void store(TF&& fn)
  {
    using typeF = typename std::decay<TF>::type;
    // Use a fixed small buffer for normal cases - so called SBO(Small Buffer Optimization).
    if constexpr(sizeof(typeF) <= BufferMaxSize) {  // since c++17.
      m_size = sizeof(typeF);
      if(m_cleaner) m_cleaner(&m_buffer);
      new (m_buffer) typeF(std::move(fn));
      m_obj = m_buffer;
      m_cleaner = cleaner<TF>;
    }
    // Allocate heap memory only if the current buffer does not fit.
    else {
      if(sizeof(typeF) > m_size || m_data.use_count() > 1) {
        m_size = sizeof(typeF);
        m_data.reset(operator new(m_size), deleter<TF>);
      }
      else m_cleaner(m_data.get());
      new (m_data.get()) typeF(std::move(fn));
      m_obj = m_data.get();
      m_cleaner = cleaner<TF>;
    }
  }

  template <typename T>
  static void cleaner(void* p) { static_cast<T*>(p)->~T(); }
  template <typename T>
  static void deleter(void* p) { static_cast<T*>(p)->~T(); operator delete(p); }

private:
  void* m_obj{nullptr};                    // object pointer
  TypeCallback m_callback{nullptr};        // callback pointer
  TypeCleaner m_cleaner{nullptr};          // pointer to a storage cleaner
  char m_buffer[BufferMaxSize];            // small buffer storage
  std::shared_ptr<void> m_data{nullptr};   // big data storage
  size_t m_size{0};                        // size of data storage
  TypeID m_id{0};                          // Slot id
};

2019/11/23

Circular Queue와 Iterator


Circular Queue(Buffer)를 c++로 구현해 보았다. Ring Buffer로 오래 전부터 네트워크 프로그램에서 많이 사용하던 놈이다. 컨테이너 Class 들은 c++을 배우고, 또 적응하는데 매우 도움이 된다. 단지 공부하려고 만든 것은 아니고 실시간 차트 데이터를 저장하는데 적용해 보려고 만든 것이다.

그 간에는 std::deque을 사용했었는데 뒤에서 채우고 앞에서 지우는 식으로 고정크기를 유지했는데, 메모리 관리를 내 맘대로 못하는게 문제였고 성능 문제가 생길 수 밖에 없었다.  아예 첨부터 고정크기 메모리를 할당해서 사용하는게 효율적이고, 실시간 데이터는 시간 단위로 저장하면 되기 때문에 필요한 메모리 크기를 사전에 예측할 수 있다. 결론은 피부로 느낄만큼 성능향상 효과가 있더라.

성능을 높이는 김에 메모리 할당 외에 fast modulo 함수를 사용했다. Circular Buffer의 특성상 메모리 상에서 현재 데이터의 위치를 빠르게 알아내야 하는데 나머지(%) 연산을 해야만 한다. 다행히도 나누는 수가 양수이고 2의 거듭제곱(power of 2)이라면 bit 연산으로 나머지를 빠르게 계산할 수 있다. 여기서 나누는 수는 고정크기 용량 또는 최대 저장 크기이다. 그리고, 사용자가 용량을 적당히 지정하더라도 무조건 가까운 크기의 2의 거듭제곱으로 용량을 설정하도록 하였다. 이렇게 하지 않으면 나머지 연산 결과가 엉뚱하게 나오기 때문이다.

Circular Queue

// Circular Queue(Buffer) and Circular Iterator C++ Implementation
//
// This program is copyright (c) 2019 by Umundu @ https://zapary.blogspot.com .
// It is distributed under the terms of the GNU LGPL version 3, as detailed in
// https://opensource.org/licenses/lgpl-3.0.html .

#include <memory>

template<typename TC>
class CircularIterator;

template<typename TD>
class CircularQueue
{
public:
  using value_type      = TD;
  using reference       = TD&;
  using const_reference = TD const&;
  using pointer         = TD*;
  using const_pointer   = TD const*;
  using difference_type = std::ptrdiff_t;
  using size_type       = std::size_t;
  using iterator        = CircularIterator<CircularQueue>;
  using const_iterator  = CircularIterator<const CircularQueue>;
  using riterator       = std::reverse_iterator<iterator>;
  using const_riterator = std::reverse_iterator<const_iterator>;

public:
  CircularQueue() = default;
  CircularQueue(size_type capacity)
    : m_capacity(toPow2(capacity)), m_data(new TD[m_capacity]{}) {}
  template<typename TI>
  CircularQueue(TI b, TI e)
    : m_capacity(toPow2(std::distance(b, e))), m_data(new TD[m_capacity]{})
  { for(auto it = b; it != e; ++it) push_back(*it); }
  CircularQueue(std::initializer_list<TD> const& il)
    : CircularQueue(std::begin(il), std::end(il)) {}
  ~CircularQueue() { destroy(); }

  CircularQueue(const CircularQueue& cq)
    : m_capacity(cq.m_capacity), m_nDequeue(cq.m_nDequeue), m_nOverflow(cq.m_nOverflow),
      m_nUnderflow(cq.m_nUnderflow), m_nRemoved(cq.m_nRemoved), m_data(new TD[m_capacity]{})
  {
    try {
      for(size_type i = 0; i < cq.m_size; ++i) push_back(cq[i]);
      // Note: m_capacity and m_size are already set.
      //       m_head and m_tail should be reset automatically in updateSize().
      m_nEnqueue = cq.m_nEnqueue;
    }
    catch(...) { destroy(); throw; }
  }
  CircularQueue& operator=(const CircularQueue& cq)
  {
    if(this != &cq) { CircularQueue<TD> tmp(cq); tmp.swap(*this); }
    return *this;
  }
  CircularQueue(CircularQueue&& cq) noexcept { cq.swap(*this); }
  CircularQueue& operator=(CircularQueue&& cq) noexcept { cq.swap(*this); return *this; }

  bool            empty() const    { return !m_size; }
  bool            full() const     { return m_size == m_capacity - 1; }
  size_type       size() const     { return m_size; }
  size_type       capacity() const { return m_capacity - 1; } // for iterators
  size_type       removed() const  { return m_nRemoved; }

  reference       at(size_type idx)
                  { validate(idx); return *(m_data + modCapacity(m_head + idx)); }
  const_reference at(size_type idx) const
                  { validate(idx); return *(m_data + modCapacity(m_head + idx)); }
  reference       operator[](size_type idx) { return *(m_data + modCapacity(m_head + idx)); }
  const_reference operator[](size_type idx) const
                  { return *(m_data + modCapacity(m_head + idx)); }
  reference       front()          { return *(m_data + m_head); }
  const_reference front() const    { return *(m_data + m_head); }
  reference       back()           { return *(m_data + (m_tail ? m_tail - 1 : m_capacity - 1)); }
  const_reference back() const     { return *(m_data + (m_tail ? m_tail - 1 : m_capacity - 1)); }

  iterator        begin()          { return iterator(this, m_data + m_head); }
  riterator       rbegin()         { return riterator(end()); }
  const_iterator  begin() const    { return const_iterator(this, m_data + m_head); }
  const_riterator rbegin() const   { return const_riterator(end()); }
  iterator        end()            { return iterator(this, m_data + m_tail); }
  riterator       rend()           { return riterator(begin()); }
  const_iterator  end() const      { return const_iterator(this, m_data + m_tail); }
  const_riterator rend() const     { return const_riterator(begin()); }
  const_iterator  cbegin() const   { return begin(); }
  const_riterator crbegin() const  { return rbegin(); }
  const_iterator  cend() const     { return end(); }
  const_riterator crend() const    { return rend(); }

  void enqueue(const value_type& item) { push_back(item); }
  void enqueue(value_type&& item) noexcept { move_back(std::move(item)); }
  template<typename... TAs>
  void enqueue(TAs&&... args) noexcept { emplace_back(std::move(args)...); }

  TD dequeue() noexcept
  {
    if(!m_size) {
      ++m_nUnderflow;
      return TD{};
    }

    TD item = std::move(*(m_data + m_head));
    m_head = modCapacity(++m_head);
    --m_size;
    ++m_nDequeue;
    ++m_nRemoved;
    return item; // Respect RVO for local objects.
  }

  void reserve(size_type capacity)
  {
    if(m_capacity >= capacity) return;
    capacity = toPow2(capacity);
    reserveData(capacity);
  }

private:
  // Return a number with a power of 2 that is larger but the most adjacent to a given value.
  size_type toPow2(size_type value) const
  {
    int hbit = 0;
    for(; value != 1; ++hbit) value >>= 1;
    return (size_type(1 << hbit) == value) ? value : 1 << (hbit + 1);
  }
  // Return a fast modulo. m_capacity is assumed a power of 2 and positive number.
  size_type modCapacity(size_type num) const { return num & (m_capacity - 1); }

  void validate(size_type idx) const
  { if(idx >= m_size || !m_capacity) throw std::out_of_range("Error: index out of range."); }

  void updateSize()
  {
    ++m_nEnqueue;
    m_tail = modCapacity(++m_tail);

    // If Queue is full(or empty) head and tail are same. This makes iterators useless.
    if(m_size == m_capacity - 1) {
      ++m_nRemoved;
      ++m_nOverflow;
      m_head = modCapacity(m_tail + 1); // Make head != tail for iterators.
    }
    else ++m_size;
  }

  void push_back(const TD& item) { *(m_data + m_tail) = item; updateSize(); }
  void move_back(TD&& item) noexcept { *(m_data + m_tail) = std::move(item); updateSize(); }
  template<typename... TAs>
  void emplace_back(TAs&&... args) noexcept
  { *(m_data + m_tail) = TD(std::move(args)...); updateSize(); }

  void reserveData(size_type capacity)
  {
    CircularQueue<TD> tmp(capacity);
    restoreData(tmp);
    tmp.swap(*this);
  }

  void restoreData(CircularQueue<TD>& cq)
  {
    if(!m_size) return;
    for(size_type i = 0; i < m_size; ++i) cq.move_back(std::move((*this)[i]));
    // Note: m_capacity and m_size are already set.
    //       m_head and m_tail should be reset automatically in updateSize().
    cq.m_nEnqueue   = m_nEnqueue;
    cq.m_nDequeue   = m_nDequeue;
    cq.m_nOverflow  = m_nOverflow;
    cq.m_nUnderflow = m_nUnderflow;
    cq.m_nRemoved   = m_nRemoved;
  }

  void swap(CircularQueue& cq) noexcept
  {
    std::swap(m_capacity,   cq.m_capacity);
    std::swap(m_size,       cq.m_size);
    std::swap(m_head,       cq.m_head);
    std::swap(m_tail,       cq.m_tail);
    std::swap(m_nEnqueue,   cq.m_nEnqueue);
    std::swap(m_nDequeue,   cq.m_nDequeue);
    std::swap(m_nOverflow,  cq.m_nOverflow);
    std::swap(m_nUnderflow, cq.m_nUnderflow);
    std::swap(m_nRemoved,   cq.m_nRemoved);
    std::swap(m_data,       cq.m_data);
  }

  void destroy() { std::unique_ptr<TD, Deleter> deleter(m_data, Deleter()); }

private:
  struct Deleter { void operator()(TD* data) const { delete[] data; } };

  size_type m_capacity{0};   // queue capacity
  size_type m_size{0};       // current data size
  size_type m_head{0};       // head index
  size_type m_tail{0};       // tail index
  size_type m_nEnqueue{0};   // enqueued data size(m_size + m_nRemoved)
  size_type m_nDequeue{0};   // dequeued data size
  size_type m_nOverflow{0};  // overflowed data size
  size_type m_nUnderflow{0}; // underflowed data size
  size_type m_nRemoved{0};   // removed(m_nOverflow + m_nDequeue) data size
  TD* m_data{nullptr};       // data storage

  friend iterator;
  friend const_iterator;
};

Circular Iterator

만드는 김에 iterator까지 만들어 보았다. 로직이 간단하지는 않아서 애를 좀 먹었다. STL 표준 iterator들은 컨테이너의 begin()과 end() 함수만으로 동작하는데, Circular Queue의 경우 두 함수가 동일한 메모리 주소를 갖는 경우가 생기기 때문에 구현하기 어렵다. 즉, Queue가 비어 있거나 꽉찼을 때 head와 tail 위치가 같아진다. loop를 아예 돌릴 수 없거나 무한 loop를 돌게 되는 상황에 빠진다.

나의 해결 방법은 head와 tail이 같아지지 않도록 하여 정확히 1 cycle의 loop이 돌게 하였다. 대신 최대 데이터 크기는 고정 용량 크기 보다 1개 줄어든다.

// Circular Queue(Buffer) and Circular Iterator C++ Implementation
//
// This program is copyright (c) 2019 by Umundu @ https://zapary.blogspot.com .
// It is distributed under the terms of the GNU LGPL version 3, as detailed in
// https://opensource.org/licenses/lgpl-3.0.html .

#include "CircularQueue.h"

template<typename TC>
class CircularIterator
{
public: // Should be public!
  using iterator_category = std::random_access_iterator_tag;
  using value_type        = typename TC::value_type;
  using pointer           = typename TC::pointer;
  using difference_type   = typename TC::difference_type;
  using size_type         = typename TC::size_type;
  using reference         = typename TC::reference;

public:
  CircularIterator() = default;
  CircularIterator(const CircularIterator& it) : m_cque{it.m_cque}, m_it{it.m_it} {}
  CircularIterator(TC* co, const pointer po) : m_cque{co}, m_it{po} {}

  CircularIterator& operator=(const CircularIterator& it)
  {
    if(this == &it) return *this;
    m_cque = it.m_cque;
    m_it = it.m_it;
    return *this;
  }

  reference operator*() const { return *m_it; }
  pointer operator->() const { return &(operator*()); }

  CircularIterator& operator++() {
    if(++m_it == m_cque->m_data + m_cque->m_capacity) m_it = m_cque->m_data;
    return *this;
  }
  CircularIterator operator++(int) {
    CircularIterator tmp = *this;
    ++*this;
    return tmp;
  }
  CircularIterator& operator--() {
    if(m_it == m_cque->m_data) m_it = m_cque->m_data + m_cque->m_capacity;
    --m_it; // note!
    return *this;
  }
  CircularIterator operator--(int) {
    CircularIterator tmp = *this;
    --*this;
    return tmp;
  }

  CircularIterator& operator+=(difference_type n) {
    if(n > 0) m_it = m_cque->m_data + m_cque->modCapacity(m_it - m_cque->m_data + n);
    else if(n < 0) *this -= -n;
    return *this;
  }
  CircularIterator& operator-=(difference_type n) {
    if(n > 0) {
      difference_type idx = m_it - m_cque->m_data;
      m_it = m_cque->m_data +
             (n > idx ? m_cque->m_capacity - m_cque->modCapacity(n - idx) : idx - n);
    }
    else if(n < 0) *this += -n;
    return *this;
  }
  CircularIterator operator+(difference_type n) const { return CircularIterator(*this) += n; }
  CircularIterator operator-(difference_type n) const { return CircularIterator(*this) -= n; }
  difference_type operator+(CircularIterator& it) const
  { return m_cque->modCapacity(index(m_it) + index(it.m_it)); }
  difference_type operator-(CircularIterator& it) const
  { return index(m_it) - index(it.m_it); }

  reference operator[](difference_type n) const { return *(*this + n); }

  bool operator!() const { return !m_it; }
  bool operator==(const CircularIterator& it) const { return m_it == it.m_it; }
  bool operator!=(const CircularIterator& it) const { return !operator==(it); }

  bool operator<(const CircularIterator<TC>& it) const
  { return (index(m_it) < index(it.m_it)); }
  bool operator>(const CircularIterator<TC>& it) const
  { return (index(m_it) > index(it.m_it)); }
  bool operator<=(const CircularIterator& it) const { return !(operator>(it)); }
  bool operator>=(const CircularIterator& it) const { return !(operator<(it)); }

private:
  difference_type index(const pointer& it) const
  {
    difference_type idx = it - m_cque->m_data - m_cque->m_head;
    return idx < 0 ? m_cque->m_capacity + idx : idx;
  }

  const TC* m_cque{nullptr}; // CircularQueue
  pointer   m_it{nullptr};   // iterator
};


Test 결과

테스트 결과만 아래에 보였다. 잘 돌아간다~!!!
===============[[  Queue: 0x7ffcd5d379a0 Status  ]]===============
=== Queue capacity(the maximum data size)        : 15
=== Current(queue keeping) data size             : 15
=== Enqueued(= Current + Removed) size           : 46
=== Dequeued size                                : 15
*** Overflowed(starving capacity) size           : 16
*** Underflowed(starving enque data) size        : 5
*** Removed(= Dequeued + Overflowed) size        : 31
=== Head/Front(next dequeue point) index (value) : 3 (8)
=== Back(the last data point) index (value)      : 1 (-48)
=== Tail(next enqueue point) index               : 2
-------------< Storage Data in memory address order >-------------
-49, -48, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, -50, 
-------------<    Current Data in enqueued order    >-------------
8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, -50, -49, -48, 
==================================================================

*** for-loop test on a Circular Queue
8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, -50, -49, -48, index loop
8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, -50, -49, -48, auto iterator
8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, -50, -49, -48, iterator
8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, -50, -49, -48, const iterator
-48, -49, -50, 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, reverse iterator
-48, -49, -50, 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, c-r iterator
-50, -49, -48, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, std::sort()

Tip: 함수 성능 측정을 위한 Simple Timer

가끔 함수 성능 측정을 해야 하는데 아래와 같은 Simple Timer 하나 장만해 두면 편하다.

#include <chrono>
#include <thread>

using namespace std::chrono_literals;
// Simple Timer
class Timer {
  using TClock = std::chrono::high_resolution_clock;
  using TTime = decltype(TClock::now());

public:
  Timer() : m_start(TClock::now()) {}
  operator long()
  {
    auto interval = std::chrono::duration_cast<std::chrono::microseconds>
                    (TClock::now() - m_start).count();
    reset();
    return interval;
  }
  void reset() { m_start = TClock::now(); }

private:
  TTime m_start;
};

아래와 같이 간단하게 사용할 수 있다.

void main()
{
  Timer now;
  for(size_t i = 0; i < 1000; ++i) [] { std::this_thread::sleep_for(2ms); };
  std::cout << "f1: " << now << '\n';

  for(size_t i = 0; i < 1000; ++i) [] { std::this_thread::sleep_for(3ms);};
  std::cout << "f2: " << now << '\n';
}