Loading the catalog…
Loading the catalog…
한 줄 요약: 당연히(?) GPU로 옮김. 다만 그 과정 중 CPU와 GPU 사이의 데이터 왕복을 줄이려다가, 효과마다 적용 영역이 다 달라서 또 생각대로 굴러가지 않기도 함 지난 편 에서는 피부 보정을 적용하면 화면이 5초 넘게 멈추는 문제를 해결하기 위해 WebGL을 도입해 봤다. 얼굴 이미지의 모공, 주름, 홍조를 보정하는 기능이었고, 한 번의 보정에 여러 효과가 연달아 적용되는 구조였다. 일단 단순한 모공 보정으로 CPU와 GPU 결과를 비교할 수 있는 실험 페이지까지 만들었다. 이번에는 실제 보정의 계산과 분기를 옮기고, 기존 처리 흐름에 연결한 뒤의 이야기다. 이번 편도 클로드 선생과 함께 진행했다. 실험 버전이 동작하는 것을 확인한 뒤, 실제 모공 보정의 계산과 분기를 GPU 쪽으로 옮겨 기존 화면에 연결했다. 이제 확인할 것은 실험 페이지의 속도가 아니라, 실제로 보정 결과를 받아 쓰기까지 얼마나 걸리는지였다. 그래서 얼굴 전체에 모공 보정을 적용하는 시간을 이미지 업로드, 그리기 명령 제출, 결과 읽기로 나눠 살펴봤다. 당시 작업 기록에 남아 있는 값은 다음과 같다. 얼굴 전체 모공 보정: 243ms ├ upload 5.5ms ├ draw 0.3ms └ readback 218.7ms 계산은 0.3ms인 것 같은데 전체는 243ms,,,? GPU 선생님은 일을 끝내셨는데,,, 결과물이 안 오는 건가 싶겠지만, 그 전에 저 0.3ms가 정확히 무엇을 잰 값인지 부터 확인해야 했다. 숫자가 작게 나왔다고 일단 좋아할 일이 아니었음. GPU에서 계산했으면 끝 아닌가요 우선 저 로그를 읽으려면 이미지가 어디로 이동하는지 알아야 한다. WebGL은 JavaScript에서 GPU에 그래픽 작업을 요청하는 API다. JavaScript 쪽에서 이미지와 설정값을 준비하고, 셰이더라는 프로그램이 GPU에서 출력할 색을 계산한다. (여기까지는 지난 편에서 언급 했었는데 다들 기억하시죠 ?)~~ 그런데 보정 기능은 GPU로만 끝나는 구조가 아니었다. GPU가 계산한 결과를 기존 Canvas 2D 처리 흐름에 다시 넣어야 했다. 즉, JavaScript가 사용할 수 있는 픽셀 데이터로 돌려받는 단계가 필요했음. CPU 쪽 이미지 데이터 → upload: GPU에 이미지 전달 → draw: 보정 계산을 위한 그리기 명령 제출 → readback: 계산한 픽셀을 CPU 쪽으로 가져옴 → 기존 Canvas 2D 처리에 연결 readback 은 GPU 쪽 결과를 CPU 쪽에서 다시 읽어 오는 작업을 말한다. 이때 사용한 WebGL API가 readPixels() 였다. 이미지를 GPU로 보내는 것도 일이고, 결과를 받아 오는 것도 일이었다. 계산만 대신 시키면 끝날 줄 알았는데 배송 과정도 있었던 것;; 처리한 얼굴 이미지는 4096×2048이었다. 각 픽셀을 빨강·초록·파랑·투명도, 네 개의 8비트 값으로 저장하면 크기는 다음과 같다. 4096 × 2048 × 4바이트 = 33,554,432바이트 = 32MiB 효과 하나를 적용할 때마다 32MiB 크기의 픽셀 데이터를 돌려받고 있었다. 숫자로 보니 이미지 한 장이라고 가볍게 부를 일이 아니었던 것,,, 위 시간은 당시 4096×2048 이미지에서 남긴 단일 실행 기록이다. 기기·브라우저와 반복 측정 통계는 남아 있지 않다. 세부 구간 합과 전체 시간에도 차이가 있어, 전체 비용을 빠짐없이 계산하진 않았다,,, draw 0.3ms가 계산 시간은 아니었다 여기서 “GPU 계산은 0.3ms인데 복사에만 218.7ms가 든다!”고 생각했지만 실상은 또 그게 아니었음. WebGL의 그리기 명령은 GPU가 작업을 끝낼 때까지 매번 기다렸다가 반환되는 방식이 아니다. JavaScript에서 명령을 제출하고 다음 코드로 넘어가는 동안 GPU는 작업을 진행할 수 있다. 당시 모공 처리 경로는 그리기 명령을 제출한 직후 시간을 기록했다. 그 사이에 GPU 작업이 끝났는지 따로 기다리지 않았고, 다음 구간에서 readPixels() 로 결과를 받았다. // 측정 위치를 설명하기 위한 개념 코드 (실제 코드와는 다름) const start = performance.now(); submitDrawCommands(); const afterDraw = performance.now(); readPixelsIntoArray(); const afterReadback = performance.now(); 여기서 afterDraw - start 를 구해도 GPU가 실제 계산한 시간 전체가 나오지는 않는다. 아직 계산이 진행 중일 수 있기 때문이다. 반면 JavaScript 배열로 결과를 즉시 받는 readPixels() 는 필요한 렌더링이 끝나야 반환할 수 있다. 그래서 이 구간에는 픽셀을 가져오는 비용뿐 아니라, 앞서 제출한 GPU 작업이 끝나기를 기다리는 시간도 들어갈 수 있다. 그동안 호출한 JavaScript 쪽도 기다린다. Khronos의 동기화 설명 에서도 이 동작을 확인할 수 있다. 따라서 이 로그에서 확인한 것은 결과를 CPU에서 받아 사용할 수 있게 되기까지의 구간이 오래 걸린다 는 점이었다. GPU의 순수 계산 시간과 복사 시간을 정확히 분리한 기록은 아니었다. 시간 재는 코드에도 해석이 필요했다. 변수 이름을 drawTime 으로 지었다고 GPU가 그 이름에 맞춰 일을 끝내주는 것은 아니었음ᄏ,,, 효과마다 이미지를 다시 가져오고 있었다 처음에는 효과별로 GPU 처리 코드를 나눴다. 모공 보정, 피부의 큰 톤 변화와 세부 질감을 나눠 조정하는 주파수 분리, 홍조 보정을 각각 옮겼다. 하나씩 옮기고 기존 결과와 비교하기에는 이쪽이 편했다. 문제는 각 효과가 입력을 받고 결과를 돌려주는 구조였다. CPU 이미지 → GPU에서 모공 보정 → CPU로 결과 가져오기 → GPU에서 피부 질감 보정 → CPU로 결과 가져오기 → GPU에서 홍조 보정 → CPU로 결과 가져오기 모공 보정 끝났으니 CPU로, 다음 효과 해야 하니 다시 GPU로. 옥천 허브 수준의 이미지 뺑뺑이 이러면 효과 안의 계산을 줄여도 이미지가 오가는 비용은 반복해서 생긴다. 특히 이 기능은 프리셋에 따라 여러 효과를 연달아 적용하고 있었기 때문에, 효과 하나의 실행 시간만 봐서는 전체 기다림을 설명하기 어려웠다. 영역별 보정에 GPU 처리를 연결했을 때는 잠시 더 느려지기도 했다. 볼처럼 일부만 보정하는데 이미지 전체를 처리하고 전송했기 때문이다. 처리 범위를 해당 영역을 감싸는 사각형으로 줄인 뒤에야 낭비를 줄일 수 있었다. 여기서 확인할 것은 두 가지였다. 한 번에 얼마나 큰 이미지를 보내고 받는가. 그 왕복을 몇 번 반복하는가. 작은 데이터를 한 번 넘기는 것과 큰 데이터를 효과마다 넘기는 것은 다른 문제였다. GPU로 옮겼다는 사실만으로는 해결이 안 됐다. 그러면 GPU 안에서 다음 효과로 넘기면 되지 않나? 중간 결과를 매번 CPU로 가져와야 할까? 바로 다음 작업도 GPU에서 할 거라면 그 안에 두고 넘겨도 되지 않을까? 이렇게 한 효과의 결과를 GPU 안에서 다음 효과의 입력으로 이어 사용하는 방식 을 편의상 체이닝(chaining)이라고 하겠음. 바꾸려던 흐름은 이랬다. CPU 이미지 → GPU에 한 번 전달 → 모공 보정 → 피부 질감 보정 → 홍조 보정 → 마지막 결과만 CPU로 가져오기 계산식을 무조건 더 줄이기보다, 중간에 필요 없이 왕복하던 단계를 줄여 보자는 접근이었다. 먼저 작업 환경부터 합치기 기존에는 각 효과가 별도의 WebGL 컨텍스트와 중간 결과 저장 공간을 가지고 있었다. 컨텍스트는 WebGL 작업에 필요한 자원과 상태를 관리하는 작업 환경이라고 생각하면 된다. 이 상태에서는 효과들을 한 흐름으로 연결하기도 번거로웠다. 그래서 하나의 컨텍스트를 쓰는 통합 처리 모듈로 정리하고, 셰이더와 중간 텍스처를 공유하도록 바꿨다. 텍스처는 GPU가 읽거나 쓸 이미지 데이터다. WebGL에서는 계산한 색을 화면에 바로 보여주는 대신 다른 텍스처에 저장할 수 있다. 그걸 다음 효과가 읽으면 중간에 JavaScript 배열로 바꿀 필요가 없다. 입력과 출력 텍스처를 번갈아 쓰는 ping-pong 방식도 사용한다. 이름은 귀여운데, 아직 읽어야 하는 데이터를 덮어쓰면 결과가 귀엽지 않게 나옴. 원본이나 통계값을 나중에 다시 쓰는 효과도 있어서, 모든 처리가 텍스처 두 장만으로 끝나는 것은 아니었다. 이 부분은 다음 편에서 더 다뤄보겠다. API도 이미지가 오가는 시점에 맞춰 나눴다 호출하는 쪽에서는 시작, 효과 적용, 결과 받기를 구분하도록 했다. 아래는 사내 구현을 그대로 옮긴 것이 아닌 개념 예시 코드 입니다. processor.begin(source); // 이미지를 GPU에 전달 processor.apply(poreEffect); // GPU 안에서 효과 적용 processor.apply(smoothEffect); // 앞 결과를 다음 입력으로 사용 const result = processor.end(); // 여기서 CPU로 결과를 가져옴 이렇게 나누면 apply() 를 호출할 때마다 결과를 읽어 올 필요가 없다. 한 번만 실행할 때 쓸 편의 API도 따로 유지했다. 다만 모듈을 합쳤다고 바로 빨라지는 건 아니다. 중간 결과를 실제로 GPU에 유지한 채 연결해야 왕복 횟수가 줄어든다. 여기까지는 그걸 시도할 수 있도록 구조를 정리한 단계였다. 붙였더니 피부 질감도 같이 사라짐 그럼 이제 이어 붙이면 되는가? 첫 시도는 연달아 실행하던 두 모공 보정을 묶는 것이었다. 각각 결과를 가져오던 작업을 한 번으로 줄일 수 있을 것 같았다. 그런데 결과를 보니 피부가 지나치게 매끈해졌다. 피부 질감이 사라져서 얼굴이 마네킹처럼 보였음. 의도: 이미지 왕복 횟수 줄이기 결과: 피부 질감 줄이기 줄이라는 걸 잘못 줄여버렸다~ 계산 순서는 그대로였는데 왜 결과가 달라졌을까. 원인은 각 효과가 적용되는 영역 이었다. 기존에는 한 보정을 특정 영역에만 잘라서 반영하고 있었다. GPU 효과를 그대로 연결하자 이 제한이 빠졌고, 원래 추가 보정을 받지 않던 부분까지 다시 보정됐다. 다음에는 모공 보정 뒤에 피부를 부드럽게 만드는 단계를 연결해 봤다. 이것도 원래는 제한된 영역에 적용하던 효과였는데, 연결한 뒤에는 눈꺼풀과 이마까지 적용 범위가 넓어졌다. 두 시도 모두 되돌렸다. 함수와 순서만 같으면 될 줄 알았는데, 어디에 적용하는지까지 같아야 같은 기능이었다. Canvas가 해 주던 일도 같이 옮겨야 했다 기존 Canvas 처리에서는 clip 으로 결과를 그릴 영역을 제한하고 있었다. 쉽게 말하면 그림을 그리기 전에 “이 선 안쪽에만 칠하기”를 정해 놓은 것이다. GPU에서 계산을 이어 붙이려면 이 제한도 단계마다 유지해야 했다. 마지막 결과를 한 번 잘라서 붙이는 것으로는 부족하다. 중간 단계에서 이미 다른 영역까지 보정했다면, 다음 효과가 그 바뀐 값을 입력으로 사용하기 때문이다. 이를 위해 필요한 것이 마스크(mask) 였다. 여기서 마스크는 각 픽셀에 효과를 얼마나 적용할지 표시한 이미지다. 마스크 값 0 → 기존 결과 유지 마스크 값 1 → 보정 결과 적용 마스크 값 0.5 → 두 결과를 절반씩 섞음 가령 볼에만 효과를 적용하고 싶다면 볼 안쪽은 1, 바깥은 0으로 표시할 수 있다. 경계에서 값이 서서히 바뀌게 만들면 보정이 갑자기 끊기는 느낌도 줄일 수 있다. 셰이더에서는 이 값을 읽어서 기존 결과와 보정 결과를 섞는다. 다음 결과 = 기존 결과 × (1 − 마스크) + 보정 결과 × 마스크 다만 마스크만 곱하면 끝 ~ 도 아니었다. 주변 평균을 계산하는 효과라면 마스크 바깥 픽셀을 계산에 포함할지까지 확인해야 한다. 결과를 어디에 쓸지 와 계산할 때 어디까지 참고할지 는 서로 다른 조건이기 때문이다. 생각보다 기존 Canvas 처리에 담긴 약속이 많았다. 셰이더의 계산식만 옮겨서는 그 약속이 자동으로 따라오지 않았다. 그래서 체이닝은 일단 보류 데이터 왕복을 줄이려는 방향 자체는 여전히 의미가 있었다. 하지만 실제로 적용하려면 효과별 영역을 GPU에서도 보존할 마스크 처리부터 필요했다. 당시에는 그 기반까지 만드는 복잡도를 감수하기보다, 기존 결과를 유지하고 다른 병목을 먼저 줄이기로 했다. 통합 모듈과 체인 API는 남기되, 문제가 생긴 효과 조합은 개별 호출로 돌려놓았다. 그래서 이번 작업을 “체이닝으로 성능 개선 완료!”라고 적으면 안 된다. 체이닝할 구조를 만들고 시도했지만, 해당 조합은 결과가 달라져 되돌렸음. 이 정도면 꽤 열심히 돌아온 것 같지만, 덕분에 다음에 필요한 일이 구체적으로 보였다. 단순히 효과를 연결하는 것이 아니라 각 단계의 적용 범위를 보존하면서 연결하는 것 이었다. WebGL2나 필요한 부동소수점 렌더링 기능을 쓸 수 없는 환경에는 기존 CPU 경로도 남겼다. GPU 최적화를 했다고 기능이 되는 환경까지 좁힐 수는 없었다. 그래서 GPU 이관 결과는요,,? 체이닝을 보류했다고 GPU 이관 전체를 접은 것은 아니다. 개별 효과의 GPU 처리, 필요한 영역만 계산하도록 하는 수정, 경계 처리 개선 등은 계속 진행했다. 작업 시작의 전체 처리 기록은 22.3초였고, 최종 기록은 약 3.2초였다. 마지막은 4096×2048 이미지에 여러 보정 항목과 12개 영역을 적용한 시나리오였다. 다만 중간에 테스트 시나리오가 바뀌었고, 전체 실행의 반복 측정 통계도 충분히 남아 있지 않다. 따라서 이 숫자는 작업 시작과 끝의 기록으로 남긴다. 동일 조건에서 7배 빨라졌다고 계산하거나, 체이닝 덕분에 줄었다고 설명할 수는 없다. 이번에 배운 것은 효과 하나를 빠르게 만드는 것과, 여러 효과를 거쳐 최종 이미지를 받는 시간을 줄이는 것이 서로 다르다는 점이었다. 계산 시간뿐 아니라 이미지가 어디에 있고, 얼마나 자주 오가며, 어느 영역에 적용되는지도 봐야 했다. 그리고 작업 중에는 조금 허무한 발견도 있었다. 기록상 한 번의 수정으로 가장 크게 줄어든 시간은 약 12초였는데, GPU 셰이더를 기가 막히게 짜서 얻은 결과가 아니었다. CPU 함수에 인자 하나가 빠져 있었다. GPU까지 모셔 와 놓고요,,,?ᄏᄏᄏ 그 이야기는 다음 편에서 계속.
What RADAR observed and classified to build this opportunity. It is what the source published, not a verification that the offer is still active.
WebGL로 이미지 텍스처 처리 최적화하기 (2): readPixels 비용 분석 & GPU 효과 체이닝. 한 줄 요약: 당연히(?) GPU로 옮김. 다만 그 과정 중 CPU와 GPU 사이의 데이터 왕복을 줄이려다가, 효과마다 적용 영역이 다 달라서 또 생각대로 굴러가지 않기도 함 지난 편 에서는 피부 보정을 적용하면 화면이 5초 넘게 멈추는 문제를 해결하기 위해 WebGL을 도입해 봤다. 얼굴 이미지의 모공, 주름, 홍조를 보정하는 기능이었고, 한 번의 보정에 여러 효과가 연달아 적용되는 구조였다. 일단 단순한 모공 보정으로 CPU와 GPU 결과를 비교할 수 있는 실험 페이지까지 만들었다. 이번에는 실제 보정의 계산과 분기를 옮기고, 기존 처리 흐름에 연결한 뒤의 이야기다. 이번 편도 클로드 선생과 함께…
Open source