2011년 11월 10일 목요일

렌더스크립트 2부

[이 글은 그래픽, 성능 튜닝, 소프트웨어 아키텍쳐 전문인 안드로이드 엔지니어 R. Jason Sams가 작성한 것입니다. - Tim Bray] (Renderscript Part 2의 번역입니다.)


렌더스크립트(Renderscript)를 소개합니다에서 기술의 짧은 개요를 보였습니다. 이 글에서는 "계산"에 대해 깊게 다룹니다. 렌더스크립트에서 "계산"은 달빅 코드에서 렌더스크립트 코드로 데이터 처리를 떠넘기는 것을 의미합니다. 렌더스크립트는 동일한 프로세스에서 수행될 수도 있고 다른 머신에서 수행될 수도 있죠.

렌더스크립트의 설계 목표

렌더스크립트는 3개의 기본 목표가 있습니다. 여기에 중요한 것부터 덜 중요한 순으로 나열하였습니다.

이식성: 애플리케이션의 코드는 모든 장비에서 수행되어야 합니다. 심지어 근본적으로 다른 장비에서도 수행되어야합니다. 현재 ARM은 다양한 변형을 가집니다. VFP가 없을 수도 있고요. NEON이 없을 수도 있죠. 레지스터 개수도 다양합니다. (주: 허니컴이 이식된 테그라 2가 NEON이 없고 VFP를 위한 레지스터 수가 부족합니다.) ARM을 넘어서면 x86을 비롯해서 다양한 CPU  아키텍쳐가 있습니다. GPU도 다양하고 DSP는 더 다양합니다.

성능: 두번째 목표는 이식성의 제한 아래에서 최대 성능을 얻는 것입니다. 렌더스크립트에선 기존 해법 보다 더 나은 성능을 이루는 게 우리 뜻입니다.

편의성: 세번째 목표는 가능한 개발을 단순하게 하자는 것입니다. 글루 코드 (주: 다른 환경의 코드를 결합시키기 위한 코드)와 기타 개발자들을 바쁘게 하는 단계를 자동화하는 겁니다.

이 세가지 목표에 의해 다양한 설계 취사 선택(trade-offs)을 가집니다. 이 설계 취사 선택이 장비에서 렌더스크립트를 달빅과 NDK와 같은 기존의 접근 방법과 차이를 만들었습니다. 다른 문제를 풀기 위해 다른 도구를 쓰는 것처럼요.

핵심 설계 결정

첫째로 우리가 어떤 언어를 쓸지 결정했습니다. 언어를 선택하려니 거의 무한한 선택지가 있었습니다. 쉐이딩 스타일 언어, C, C++가 고려되었죠. 씬 그래프 같은 그래픽 응용에 필요한 자료 구조를 다룰 필요가 있어서 쉐이딩 언어는 뺐습니다. 포인터와 재귀 구문을 빼면 너무 불편하고요. C++은 매력적이지만 이식성의 문제가 있었습니다. C++의 고급 기능들은 CPU가 없는 하드웨어에서 수행되기 곤란합니다. 결국 우리는 렌더스크립트가 C99를 기반으로 하도록 하였습니다. 우리가 원하는 만큼 성능을 제공하고 개발자에게 친숙하며 많은 하드웨어에서 문제가 없기 때문이죠.

다음 설계 취사 선택은 작업 흐름입니다. 우리는 특히 소스 코드를 어떻게 기계 코드로 바꾸느냐에 중점을 두었습니다. 렌더스크립트 개발 중에 우리는 여러 선택지를 탐구했고 두 개의 다른 방법을 구현해보았습니다. (이클레어 부터 진저브레드까지의) 옛날 판은 C 소스 코드를 장비의 기계 코드로 바로 컴파일하였습니다. 이 방법은 애플리케이션을 위해 즉시 소스를 만드는 것 까지는 동일했지만 편의성 문제가 있었습니다. 앱을 컴파일하고 설치하고 수행하면 고통스러운 문맥 에러가 보였습니다. 또 성능이 약한 CPU를 가진 장비에서는 정적 분석과 수행할 수 있는 최적화의 범위가 제한되는 문제가 있었습니다. (주: 이전 렌더스크립트는 acc 컴파일러 기반이지만 허니컴부터 LLVM 기반으로 변경되었습니다.)

수정된 clang을 이용하는 LLVM(주: clang은 LLVM 프로젝트의 C 언어 구현입니다.)으로 교체한 결과 호스트에서 스크립트(주: 렌더스크립트를 모두 스크립트로 줄여부릅니다.)를 컴파일하고 분석하는 모델로 갈 수 있었습니다. 이 단계에서 높은 수준의 최적화를 수행하여 LLVM 비트코드 (bitcode) 를 만들었습니다. 중간단계의 비트코드에서 기계 코드로 바꾸는 처리는 여전히 장비에서 이루어집니다. (추가적인 장비 특화의 최적화와 함께요.)

마지막 큰 계산을 위한 취사 선택은 쓰레드 시작입니다. 이 취사 선택은 성능과 이식성 사이에 있습니다. 주어진 정보로 기존 계산 방식를 사용하면 특정 하드웨어에만 최적화되고 다른 하드웨어에선 손해입니다. 시간이 무한하고 개발자가 충분하면 모든 하드웨어 조합마다 최적화할 수 있습니다. 다양한 장비에서 실험하고 최적화하는 것은 결코 나쁘지 않지만 그들이 가지지 않은 미 출시된 하드웨어를 위한 최적화는 불가능합니다. 더 이식성 있는 방법은 최고 성능에 드는 비용을 이용하여 평균 이상의 성능을 제공할 수 있도록 실시간에 부담을 주는 겁니다. 이식성을 첫째 목표로 두었기 때문에 우리는 실시간에 부담을 주기로 결정했습니다.

실시간 쓰레드 개시 관리를 선택하여 얻은 두번째 효과는 스크립트가 어디에서 수행될지가 동적으로 결정되는 점입니다. 예를 들어 어떤 계산 하드웨어는 포인터와 재귀를 지원하지만 어떤 것은 못합니다. 우리는 이런 걸 막고 낮은 공통 분모의 API를 개발자에게 제공할 수도 있었습니다. 하지만 우리는 그 대신 실시간 스크립트 분석을 선택했습니다. 개발자는 이 기능들을 제공하는 하드웨어를 최대한 활용할 수 있습니다. 언제나 충분한 기능을 지원하는 CPU에게 의지할 수 있기 때문입니다. 결국 개발자는 좋은 앱을 작성하는 것에 집중할 수 있고 하드웨어 제조사는 가장 기능이 충실하고 효과적인 하드웨어를 만드는 것을 완성할 수 있습니다. 새 기능이 나오면 애플리케이션은 코드 수정없이 그 이점을 얻게 됩니다.

편의성은 렌더스크립트의 주요 동인(driver)였습니다. 기존의 대부분 계산과 그래픽 플랫폼은 정성들인 글루 로직 (주: 이종 환경의 코드들을 서로 이어주는 부분) 으로 높은 성능의 코드를 핵심 애플리케이션 코드와 결합하였습니다. 이 코드는 결함을 만들 가능성이 높고 대체로 작성하기 어렵습니다. 호스트 렌더스크립트 컴파일러에서 수행하는 정적 분석이 이 문제를 푸는데 도움이 됩니다. 각 사용자 스크립트는 달빅의 "글루" 클래스를 생성합니다. 글루 클래스의 이름과 접근자는 스크립트의 내용에서 도출됩니다. 이게 달빅으로부터 스크립트 사용을 매우 간결하게 합니다.

예: 애플리케이션 수준

주어진 취사 선택을 사용하면 간단한 계산 애플리케이션은 어떤 모습일까요? 이 매우 기초적인 예제에선 일반적인 android.graphics.Bitmap을 취하고 이것을 단색으로 변경하여 두번째 bitmap으로 복사하는 스크립트를 수행합니다. 스크립트 자체를 보기 앞서 스크립트를 수행하는 애플리케이션 코드를 봅시다. 이는 SDK 샘플의 HelloCompute에서 가져온 것입니다.

    private Bitmap mBitmapIn;
    private Bitmap mBitmapOut;
    private RenderScript mRS;
    private Allocation mInAllocation;
    private Allocation mOutAllocation;
    private ScriptC_mono mScript;

    private void createScript() {
        mRS = RenderScript.create(this);

        mInAllocation = Allocation.createFromBitmap(mRS, mBitmapIn,
                                                    Allocation.MipmapControl.MIPMAP_NONE,
                                                    Allocation.USAGE_SCRIPT);
        mOutAllocation = Allocation.createTyped(mRS, mInAllocation.getType());

        mScript = new ScriptC_mono(mRS, getResources(), R.raw.mono);

        mScript.set_gIn(mInAllocation);
        mScript.set_gOut(mOutAllocation);
        mScript.set_gScript(mScript);
        mScript.invoke_filter();
        mOutAllocation.copyTo(mBitmapOut);
    }
이 함수는 두개의 비트맵이 이미 생성되어 있고 같은 크기와 형태라고 가정합니다.

렌더스크립트 애플리케이션이 필요한 첫번째는 context 객체입니다. 이것은 다른 렌더스크립트 객체 전부를 생성하고 관리하는 핵심 객체입니다. 함수의 첫줄은 그 객체 mRS를 생성합니다. 이 객체는 애플리케이션이 이 객체를 사용하거나 이 객체로 만든 다른 객체를 사용하는 동안 살아있어야 합니다.

다음 두 함수 호출은 Bitmap을 위한 계산 할당 (allocation) 입니다. 렌더스크립트는 고유의 메모리 할당자 (allocator) 를 가집니다. 메모리가 잠재적으로 여러 프로세스에 공유될 수 있고 하나 이상의 메모리 주소에 존재할 수 있기 때문입니다. 할당자가 생성될 때 잠재적인 용법들이 나열되어 시스템이 현재 사용에 맞는 메모리 타입을 선택하게 됩니다.

첫번째 함수 createFromBitmap()는 적합한 렌더스크립트 할당 객체를 만들고 할당 안에 비트맵의 내용을 복사합니다. 할당은 렌더스크립트의 메모리 사용의 기본 단위입니다. createTyped()은 구조적으로 첫번째 할당과 동일한 두번째 할당을 만듭니다 구조의 정의는 첫째 할당을 getType()을 질의해 얻습니다. 렌더스크립트 형은 할당의 구조를 정의합니다. 이 경우 형은 높이, 너비, 입력된 비트맵의 형태에서 만들어집니다.

다음 줄은 "mono.rs" 이름의 스크립트를 읽습니다. R.raw.mono가 이를 확인합니다. (주:  R.raw.명칭에 렌더스크립트 파일명이 들어 있습니다.) 스크립트는 애플리케이션의 APK안의 raw 리소스로 포함됩니다. 자동으로 생성된 "글루" 클래스의 이름 ScriptC_mono를 유의하세요.

다음 세줄은 "글루" 클래스에 생성된 메서드를 이용해 스크립트의 속성들(properties)을 설정합니다.

이제 우리는 모든 것을 설정했고요. invoke_filter() 함수가 실제로 우리를 위해 어떤 일을 수행합니다. 스크립트의 filter() 함수가 호출되고 만약 이 함수가 인자를 받는다면 여기에서 전달됩니다. 반환값은 호출이 비동기이기 때문에 허용되지 않습니다.

함수의 마지막 줄은 계산 스크립트의 결과를 다시 관리되는 (managed) 비트맵에게 복사합니다. 이 코드는 동기화 코드가 내장되어 있어 스크립트가 완전히 수행된 것을 확인합니다. (주: "관리되는 (managed)"은 마이크로소프트가 닷넷을 소개하며 표현한 문구로 가상 머신에서 수행되는 것을 의미합니다. 렌더스크립트 계산 결과를 자바 객체로 복사했다는 의미입니다.)

예: 스크립트

mono.rs에 저장된 이 렌더스크립트는 위의 렌더스크립트 코드에서 호출됩니다.

#pragma version(1)
#pragma rs java_package_name(com.android.example.hellocompute)

rs_allocation gIn;
rs_allocation gOut;
rs_script gScript;
const static float3 gMonoMult = {0.299f, 0.587f, 0.114f};
void root(const uchar4 *v_in, uchar4 *v_out, const void *usrData, uint32_t x, uint32_t y) {
    float4 f4 = rsUnpackColor8888(*v_in);

    float3 mono = dot(f4.rgb, gMonoMult);
    *v_out = rsPackColorTo8888(mono);
}
void filter() {
    rsForEach(gScript, gIn, gOut, 0);
}

첫번째 줄은 단순히 컴파일러에게 이 스크립트를 작성한 네이티브 렌더스크립트 API  리비전이 무엇인지 알려주는 것입니다. 두번째 줄은 생성된 반영 코드 (reflected code) 와 연관된 패키지를 조절합니다. (주: 반영 코드는 다른 환경의 내용을 보고, 변경할 수 있는 코드를 의미합니다. 여기에선 렌더스크립트의 속성을 자바에서 변경할 수 있게 해주는 글루 코드를 의미합니다.)

나열된 세 전역 변수는 우리의 관리되는 코드(managed code)에 설정한 전역변수와 관련이 있습니다. 네번째 전역 변수는 static이기 때문에 반영되지 않습니다. 비 정적 (non-static), 상수(const), 전역에 대해서만 반영된 메서드를 생성합니다. 스크립트와 관리되는 (managed) 코드 사이의 동기화에서 상수가 유지되는 것은 꽤 유용합니다.

함수 root()는 렌더스크립트에서 특별합니다. 개념적으로 C의 main()과 비슷하죠. 실시간으로 스크립트가 수행될 때 이 함수가 호출됩니다. 이 경우 매개 변수는 우리 할당의 입력, 출력 픽셀입니다. 요청이 처리할 할당의 주소를 보통의 포인터로 사용할 수 있습니다. 이 예제에서는 해당 매개 변수를 사용하지 않습니다.

저 root 함수의 세번째 줄은 RGBA_8888으로 부터 4개의 float형의 벡터로 픽셀을 푸는 것입니다. (주: 렌더스크립트는 float 자료형이 뭉쳐 벡터형을 만들고 이를 float3, float4라 부릅니다. vec3, vec4라 부르는 OpenGL과는 조금 다른 컨벤션을 가지고 있습니다.) 그 다음 줄은 입력된 픽셀 데이터과 흑백 상수들(monochrome constants)를 내장 수학 함수를 사용해 내적(dot product)하여 회색 수준(grey scale)을 계산합니다. 내적이 하나의 float을 리턴할 수 있지만 값이 단순히 float3의 x,y,z 요소로 복제된 float3을 반환한다는 점을 유의하세요. (주: 3D 그래픽에서 벡터의 요소를 x, y, z, w라고 부릅니다. 이는 좌표를 의미하기도 하며 컬러 rgba를 순서대로 의미하기도 합니다.)  마지막으로 우리는 또 내장 함수를 사용하여 float형들을 32비트 픽셀로 다시 묶습니다. 여기서 쓰여진 것은 오버로드된 함수의 예인데요. rsPackColorTo8888가 RGBA (float4)를 받지 않고 RGB (float3)를 받는 것을 볼 수 있습니다. A를 쓰지 않으면 오버로드 함수에 의해 이 값이 1.0f로 여겨지죠.

filter() 함수는 관리된 코드에서 변환을 위해 부릅니다. 이걸 단순히 할당의 매 요소마다 호출하죠. 첫 번째 매개 변수는 수행될 스크립트입니다. 이 스크립트의 root 함수가 할당의 매 요소를 위해 호출됩니다. 두 번째 세 번째 매개 변수는 입력과 출력 자료 할당들입니다. 마지막 매개 변수는 우리가 추가적은 정보를 root 함수에 전달하기 원할 때 사용하는 사용자 데이터용 포인터입니다.

forEach는 장비가 다중 프로세서를 지원한다면 여러 쓰레드를 사용합니다. 향후 forEach는 하나의 프로세서에서 다른 프로세서로 제어를 넘기는 전환점을 제공하게 됩니다. 이 예제의 filter()가 CPU에서 수행되고 root()가 GPU나 DSP가 수행되는 것을 기대할 수 있을 겁니다.

저는 이 글이 렌더스크립트 뒤의 설계에 대한 일별과 예제 코드가 동작하는 방법을 전달했으면 합니다.

댓글 없음:

댓글 쓰기