Pull to refresh

Renderscript часть вторая

Reading time 8 min
Views 5.7K
Original author: R. Jason Sams
Renderscript — новая фича, введенная в Honeycomb. Также известно, что ранее Renderscript уже использовался разработчиками Android'a (например встроенные живые обои в 2.1(Eclair) были написаны на нём). Так или иначе, полный доступ к API был открыт только в Honeycomb. В первой вводной статье из блога разработчиков (оригинал|перевод) обещалось, что скоро будет вторая, с более подробным описанием архитектуры Renderscript и примером его использования. Собственно, под катом и то и другое.


Здесь и далее от лица инженера из команды разработчиков Android'a, R. Jason Sams'a.

В статье введение в Renderscript я привел краткий обзор этой технологии. В этом посте я буду концентрироваться на «вычислениях». В терминах Renderscript «вычисление» означает некую разгрузку обработки данных с кода Dalvik на код Renderscript, который в свою очередь может запускаться на том же процессоре, или на других (на нескольких).

Цели проектирования Renderscript


У Renderscript есть три главных цели, рассмотрим их в порядке от более важных к менее важным:

Переносимость: Код приложений должен запускаться на всех устройствах, даже если их аппаратные начинки радикально отличаются друг от друга. Сейчас ARM поступает в нескольких вариантах — с поддержкой VFP и без нее, с поддержкой NEON и без нее, а также с разными счетчиками регистров. Кроме ARM есть другие архитектуры ЦПУ, похожие на x86, также разные ГПУ и еще больше различных DSP (Digital Signal Processors).

Производительность: Вторая цель — это получение такого высокого уровня производительности, который только возможен, но с ограничениями по переносимости. Для Renderscript мы попытались достичь намного большей производительности, по сравнению с уже установленными решениями.

Юзабилити: Третьей целью было упростить разработку настолько, насколько это возможно. Мы автоматизировали некоторые шаги разработки, насколько это было возможно, чтобы избежать появление «склеивающего» кода (glue code) и другой ненужной загрузки разработчика.

Эти три цели ограничивают проектирование и ведут к некоторым компромиссам. Это те компромиссы, которые отделяют Renderscript от уже существующих решений, таких как Dalvik или NDK. Их следует рассматривать как разные инструменты, которые служат для выполнения разных задач.

Главные решения на стадии проектирования


Первое решение, которое нам нужно было принять — это язык разработки. Когда выбор стоит в том, какой язык использовать, есть почти неограниченный набор вариантов. Как языки для программирования шейдеров были рассмотрены C и C++. Впоследствии, из-за необходимости манипуляции со структурами данных для графических приложений, такими как сцены, пришлось отказаться от шейдерных языков. Отсутствие указателей и рекурсии были рассмотрены как ограничения юзабилити. С другой стороны, использование С++ было бы желательно, но тогда пришлось бы столкнуться с ограничениями по переносимости. Расширенные возможности C++ сложно запускать на не-процессорной аппаратуре. В итоге, мы решили положить в основу Renderscript стандарт C99, потому что он предлагает одинаковую производительность для остальных решений, хорошо понимаем разработчиками и не генерирует никаких проблем для запуска на большом диапазоне различной аппаратуры.

Второй компромисс — это сам рабочий процесс Renderscript. Особенно мы концентрировались на том, как компилировать исходный код в машинный. Исследовав различные варианты во время разработки, мы реализовали два решения. Старая версия (между Eclair и Gingerbread) полностью компилировала исходный код на C в машинный. В то время как это давало такие возможности приложениям как генерация кода на лету, оно обернулось проблемой юзабилити. Было очень неудобно скомпилировать приложение, установить его, запустить, и только потом найти синтаксическую ошибку. Также слабые ЦПУ были обделены возможностью статического анализа и некоторыми оптимизациями, которые могли бы быть сделаны.

Тогда мы и переключились на LLVM (виртуальную машину низкого уровня, она компилирует скрипт в платформо-независимый байткод), переходя к модели, в которой скрипты компилируются и анализируются на хосте (устройстве, на котором выполняется скрипт), используя модифицированную версию clang (кто не знает — это фронт-энд для компилятора LLVM). На этой стадии мы производим несколько высокоуровневых оптимизаций, после чего выделяем байткод LLVM. Перевод из промежуточного байткода в машинный происходит на хосте (с дополнительными специфическими для устройства оптимизациями).

Последним крупным компромиссом при проектировании был запуск потоков. Это компромисс между производительностью и переносимостью. При наличии достаточных знаний, существующие вычислительные решения позволяют разработчику настроить приложение для определенной аппаратной платформы, в ущерб другим. Учитывая неограниченное время и ресурсы разработчики могут настроить приложение для любых комбинаций аппаратного обеспечения. Хотя тестирование и настройка под некий набор устройств не есть плохо, никакой объем работы не позволит подстроить приложение для аппаратуры, которая еще не выпущена и отсутствует у разработчика. Более переносимым решением было бы производить анализ во время выполнения, также это обеспечивает большую среднюю производительность, за счет пиковой производительности. Учитывая, что нашей целью номер один была переносимость, то мы выбрали это решение.

Вторым эффектом от выбора управления запуском потоков во время выполнения стали динамические решения о том, где запускать скрипт. Например, некоторая вычислительная аппаратура поддерживает указатели и рекурсию, тогда как другая нет. Мы могли бы и запретить эти вещи и дать разработчикам некое подобие наименьшего общего знаменателя API, но вместо этого мы выбрали анализ во время выполнения. Это позволяет разработчикам использовать все возможности аппаратуры, какие поддерживаются, хотя то же время есть полнофункциональный ЦПУ, на который можно рассчитывать всегда. В конце концов, разработчики могут сфокусироваться на написании хороших приложений, в то время как производители аппаратуры могут работать над полнофункциональным и мощным железом. Как только появляется новая возможность выигрыша в производительности, то приложение использует её, без каких-либо изменений кода.

Юзабилити было нашей главной целью во время проектирования. Большинство существующих вычислительных и графических решений требуют наличия «склеивающей» логики, чтобы связать высокопроизводительный код с кодом приложения. Такой код предрасположен к появлению ошибок и является проблемным для написания. Статический анализ, который выполняется Renderscript'ом на хосте помогает решить эту проблему. Каждый пользователь скрипта создает свой «склеивающий» Dalvik-класс. Имена склеивающего класса и его аксессоров являются производными от скрипта. Это упрощает использование скрипта из Dalvik.

Пример: Уровень приложения


Учитывая все вышеозвученные компромиссы, как должен выглядеть простой пример вычислительного приложения? В этом базовом примере мы возьмем обычный объект android.graphics.Bitmap и запустим скрипт, который скопирует его в другой Bitmap, параллельно с этим конвертируя его в монохромный. Давайте взглянем на код приложения, который вызывает скрипт, прежде чем смотреть сам скрипт; пример из HelloCompute SDK:
    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);

Этот метод подразумевает, что два Bitmap'a уже созданы и имеют одинаковый размер и формат. Первая вещь, которая нужна Renderscript приложениям — это объект контекста. Это центральный объект, который используется для создания и управления других объектов Renderscript. Первая строчка кода создает объект mRS, этот объект должен оставаться живым, пока приложение намерено использовать его или любые объекты, созданные с ним.

Следующие два метода вызывают создание размещений (allocates) из Bitmap'ов для вычисления. У Renderscript есть свой распределитель (allocator) памяти, потому что есть вероятность того, что память будет делиться между несколькими процессорами и возможно существование более одного пространства памяти. Когда распределитель создан, то его потенциальные использования должны быть пронумерованы, чтобы система могла определить нужный тип памяти для предполагаемых задач.

Первый метод createFromBitmap() создает размещение и копирует в него содержимое Bitmap'a. Размещения — это базовые единицы памяти, которые использует Renderscript. Второе размещение созданное при помощи createTyped() генерирует размещение идентичное первому. Причем определение этой структуры возвращается запросом getType() от первой. Типы Renderscript определяют структуру размещения. В этом случае тип генерируется при помощи высоты, ширины и формата входного Bitmap'a.

Следующая строка загружает скрипт, который назван «mono.rs», для его получения используется R.raw.mono. Сам скрипт хранится как raw-ресурс в пакете приложения (в APK). Заметьте имя сгенерированного «склеивающего» класса, ScriptC_mono.

Следующие строки устанавливают свойства скрипта, используя сгенерированные методы «склеивающего» класса.

Теперь всё готово, хотя на самом деле метод invoke_filter() сделал за нас некоторую работу. Суть в вызове метода filter() самого скрипта «mono.rs». Если метод имел бы параметры, то их нужно передавать именно здесь. Возвращение значений запрещено, так как вызовы являются асинхронными.

Последняя строка копирует результат работы скрипта в Bitmap. В ней есть некоторый синхронизирующий код, встроенный для того, чтобы убедиться в том, что скрипт завершил выполнение.

Пример: скрипт


Вот и сам скрипт «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);
}

Первая строка скрипта просто указывает компилятору на то, для какой ревизии Renderscript он написан. Вторая строка контролирует ассоциацию сгенерированного рефлексивного кода с пакетом приложения.

Далее идут три глобальных переменных, которые соответствуют тем, которые были созданы в управляющем коде. Четвертая глобальная переменная не рефлексивна, так как является static'ом. Константы всегда лучше помечать static'ом, потому что наш код скрипта синхронизируется с управляющим.

Метод root() является особенным для Renderscript. Концептуально, он аналогичен методу main() в языке C. Когда во время выполнения вызывается скрипт, то именно эта функция и является точкой входа. В данном случае параметрами являются пиксели из наших размещений. Общий указатель пользователя также обеспечивается с адресом внутри самого размещения, которое обрабатывается этим вызовом. В этом примере эти параметры игнорируются.

Три строки кода в методе root() распаковывают пиксели из RGBA_8888 в вектор из float4. Вторая строка использует встроенную математическую функцию dot, которая вычисляет скалярное произведение монохромных констант с входящими пикселями, чтобы получить монохромную картинку. Заметьте, что dot возвращает обычный float, который можно присвоить к float3, который просто копирует значение в каждый компонент x, y и z. В конце мы снова используем встроенное средство для упаковки значений в обычный 32-битный пиксель. Также это пример перегрузки метода, так как есть разные версии rsPackColorTo8888, которые принимают данные в виде RGB(float3) и RGBA(float4).

Метод filter() вызывается из управляющего кода для того, чтобы произвести преобразование. Он просто выполняет запуск вычисления на каждом элементе размещения. Первый параметр означает, что скрипт запускается, то есть метод root() вызывается для каждого элемента размещения. Второй и третий параметры — это входные и выходные размещения данных. Последний параметр предназначен для передачи каких-либо пользовательских данных методу root().

forEach будет запущен на нескольких процессорах, если они будут на устройстве. В будущем forEach сможет обеспечивать точки перехода, где контроль может переходить от одного процессора к другому. В этом примере обоснованно было бы предположить, что в будущем filter() будет исполняться на CPU, а root() на GPU или DSP.

Я надеюсь, что это дало вам возможность поближе заглянуть в дизайн Renderscript и на простом примере ознакомиться с тем, как оно работает.

От переводчика: был бы рад услышать любые комментарии разработчиков, которые так или иначе начали работать с этой технологией.
Tags:
Hubs:
+20
Comments 10
Comments Comments 10

Articles