Данная справка относится к модулю OpenCLABC
, входящему в состав стандартных модулей языка PascalABC.NET
.
Модуль OpenCLABC
это высокоуровневая обёртка модуля OpenCL
.
Это значит, что с OpenCLABC
можно писать гораздо меньше кода в больших и сложных программах,
однако такой же уровень микроконтроля как с модулем OpenCL
недоступен.
Например, напрямую управлять cl_event
'ами в OpenCLABC
невозможно.
Вместо этого надо использовать операции с очередями (например, сложение и умножение очередей)
Справка модуля OpenCL
отсутствует. Вместо неё смотрите:
OpenGL
и OpenCL
OpenCL
, на которой основан модуль OpenCL
Если в справке или модуле найдена ошибка, или чего-либо не хватает - пишите в issue.
CPU — Центральное Процессорное Устройство (процессор);
GPU — Графическое Процессорное Устройство (видеокарта);
RAM — Оперативная память;
Команда — запрос на выполнение чего-либо. К примеру:
Называть процедуры и функции командами ошибочно!
Подпрограмма — процедура или функция;
Метод — особая подпрограмма, вызываемая через экземпляр:
Context.SyncInvoke
выглядит в коде как cont.SyncInvoke(...)
, где cont
— переменная типа Context
;Остальные непонятные термины можно найти в справке PascalABC.NET
или в интернете.
Сам модуль OpenCLABC
- обёртка модуля OpenCL
. Это значит, что внутри он использует
содержимое OpenCL
, но при подключении - показывает только свой личный функционал.
Так же множество типов из OpenCLABC
являются обёртками типов из OpenCL
.
Тип CommandQueue
использует тип cl_command_queue
, но предоставляет очень много не связанного с cl_command_queue
функционала.
"Простые" типы-обёртки модуля OpenCLABC
предоставляют только функционал соответствующего
типа из модуля OpenCL
... в более презентабельном виде. Из общего - у таких типов есть:
Свойство .Native
, возвращающее внутренний неуправляемый объект из модуля OpenCL
.
Если вам не пришлось, по какой-либо причине, использовать OpenCLABC
и OpenCL
вместе - это свойство может понадобится только для дебага.
Свойство .Properties
, возвращающее объект свойств внутреннего объекта.
Свойства неуправляемого объекта никак не обрабатываются и не имеют описаний.
Но типы этих свойств всё равно преобразуются в управляемые (особо заметно на строках и массивах).
В списке устройств контекста могут быть только совместимые друг с другом устройства.
Коллекция совместимых устройств называется платформой и хранится в объектах типа Platform
.
Обычно платформы получают из статического свойства Platform.All
:
## uses OpenCLABC;
var pls := Platform.All;
pls.PrintLines;
Обычно устройства получают статическим методом Device.GetAllFor
:
## uses OpenCLABC;
foreach var pl in Platform.All do
begin
Writeln(pl);
var dvcs := Device.GetAllFor(pl, DeviceType.DEVICE_TYPE_ALL);
if dvcs<>nil then dvcs.PrintLines;
Writeln('='*30);
end;
И в большинстве случаев - это всё что вам понадобится.
Но если где то нужен более тонкий контроль - можно создать несколько виртуальных
под-устройств, каждому из которых даётся часть ядер изначального устройства.
Для этого используются методы .Split*
:
## uses OpenCLABC;
var dvc := Context.Default.MainDevice;
Writeln('Поддерживаемые типы .Spilt-ов:');
var partition_properties := dvc.Properties.PartitionProperties;
if (partition_properties.Length=0) or (partition_properties[0].val = System.IntPtr.Zero) then
begin
Writeln('Ничего не поддерживается...');
exit;
end else
partition_properties.PrintLines;
Writeln('='*30);
Writeln('Виртуальные устройства, по 1 ядру каждое:');
if dvc.CanSplitEqually then
// Если упадёт потому что слишком много
// устройств - пожалуйста, напишите в issue
dvc.SplitEqually(1).PrintLines else
Writeln('Не поддерживается...');
Writeln('='*30);
Writeln('Два устройства, 1 и 2 ядра соответственно:');
if dvc.CanSplitByCounts then
dvc.SplitByCounts(1,2).PrintLines else
Writeln('Не поддерживается...');
Writeln('='*30);
Контекст (объект типа Context
) содержит информацию об устройствах,
на которых надо выделяет области памяти OpenCL
и выполнять код для GPU.
Создать контекст можно конструктором (new Context(...)
).
Контекст можно и не создавать, используя везде свойство Context.Default
.
Если в операции, требующей контекст (как выделение памяти GPU),
его не указать - будет автоматически выбран Context.Default
.
Неявные очереди всегда используют Context.Default
.
Изначально этому свойству присваивается контекст, использующий GPU, если оно имеется, или любое другое устройство, поддерживающее OpenCL, если GPU отсутствует.
Если устройств поддерживающих OpenCL
нет - Context.Default
будет nil
.
Однако такая ситуация на практике невозможна, потому что OpenCL поддерживается
практически всеми современными устройствами, занимающимися выводом изображения на экран.
Если Context.Default = nil
- переустановите графические драйверы.
Context.Default
можно перезаписывать.
Используйте эту возможность только если во всей программе нужен общий контекст, но не стандартный.
Если ваша программа достаточно сложная чтобы нуждаться в нескольких контекстах - лучше не использовать
Context.Default
. И присвоить ему nil
, чтобы не использовать случайно (к примеру, неявной очередью).
Объект типа Kernel
представляет одну подпрограмму в OpenCL-C коде,
объявленную с ключевым словом __kernel
.
Обычно Kernel
создаётся через индексное свойтсво ProgramCode
:
var code: ProgramCode;
...
var k := code['KernelName'];
Тут 'KernelName'
— имя подпрограммы-kernel'а в исходном коде (регистр важен!).
Так же можно получить список всех kernel'ов объекта ProgramCode
, методом ProgramCode.GetAllKernels
:
var code: ProgramCode;
...
var ks := code.GetAllKernels;
ks.PrintLines;
Обычные программы невозможно запустить на GPU. Для этого надо писать особые программы.
В контексте OpenCL - эти программы обычно пишутся на языке "OpenCL C" (основанном на языке "C").
Язык OpenCL-C это часть библиотеки OpenCL, поэтому его справку можно найти там же, где и справку OpenCL.
В OpenCLABC
OpenCL-C код хранится в объектах типа ProgramCode
.
Объекты этого типа используются только как контейнеры.
Один объект ProgramCode может содержать любое количествово подпрограмм-kernel'ов.
Самый простой способ создать ProgramCode
- конструктором:
var code := new ProgramCode(
ReadAllText('file with code 1.cl'),
ReadAllText('file with code 2.cl')
);
Внимание! Этот конструктор принимает именно тексты исходников, не имена файлов.
Если надо передать текст из файла - его надо сначала явно прочитать.
Так же как исходники паскаля хранят в .pas файлах, исходники OpenCL-C кода хранят в .cl файлах.
На самом деле это не обязательно, потому что код даже не обязан быть в файле:
var code_text := '__kernel void k() {}';
var code := new ProgramCode(code_text);
Так как конструктор ProgramCode
принимает текст - исходники программы
на языке OpenCL-C можно хранить даже в строке в .pas программе.
Тем не менее, хранить исходники OpenCL-C кода в .cl файлах обычно удобнее всего.
После создания объекта типа ProgramCode
из исходников можно вызвать
метод ProgramCode.SerializeTo
, чтобы сохранить код в бинарном и прекомпилированном виде.
Затем, объект ProgramCode
можно пере-создать статический метод ProgramCode.DeserializeFrom
.
Пример можно найти в папке примеров Прекомпиляция ProgramCode
или тут.
Типы-простые_обёртки памяти OpenCL (cl_mem
) рассмотрены в
этой папке.
Методы, запускающие Kernel
принимают специальные аргументы типа KernelArg
, которые передаются в OpenCL-C код.
Экземпляр KernelArg
может быть создан из нескольких типов значений, а точнее:
## uses OpenCLABC;
var k: Kernel;
var val1 := 3;
var val2 := 5;
var val3 := new NativeValue<byte>(7);
var a: array of byte;
k.Exec1(1,
// Передавать можно:
// Области памяти
new CLArray<byte>(1),
new MemorySegment(1),
// Очереди возвращающие область памяти
HFQ(()->new MemorySegment(1)),
// В том числе CLArrayCCQ и т.п.
CLArray&<byte>.Create(1).NewQueue,
// Указатель на размерное значение
// (в kernel попадёт само значение, не указатель)
@val2,
// Так нельзя, потому что val1 была захвачена лямбдой из HFQ ниже
// @val1,
// Расширенный набор параметров для передачи адреса
KernelArg.FromData(new System.IntPtr(@val2), new System.UIntPtr(sizeof(integer))),
// Размерное значение
val1,
HFQ(()->val1),
// В том числе неуправляемое
val3,
HFQ(()->val3),
// Массив размерных значений
a,
HFQ(()->a)
);
Обратите внимание, KernelArg
из указателя на val2
будет немного эффективнее
чем KernelArg
из самого значения val2
. Но эту возможность стоит использовать
только как тонкую оптимизацию, потому что много чего может пойти не так.
Если передавать @val2
в качестве KernelArg
- надо знать все тонкости.
Если @x
передали вкачестве KernelArg
:
x
не может быть глобальной переменной или полем класса:
uses OpenCLABC;
type
t1 = class
val1 := 1; // Не подходит, потому что поле класса
static val2 := 2; // И статических полей это тоже касается
end;
var
val3 := 3; // Глобальные переменные - тоже статические поля
k: Kernel; // k не важно где объявлена
procedure p1;
// Теоретически подходит, но вообще это плохой стиль кода
var val4 := 4;
begin
// Однозначно подходит, потому что объявлена не только в той же
// подпрограмме, но и прямо перед использованием в k.Exec*
var val5 := 5;
k.Exec1(1,
// Это единственные 2 переменные, которые можно передавать адресом
@val4,
@val5
);
end;
begin end.
x
не должно быть захвачего лямбдой.
Хотя указатель на x
уже можно захватывать:
## uses OpenCLABC;
var k: Kernel;
var val1 := 3;
// На val1 всё ещё накладываются все ограничения,
// когда val1_ptr использована в качестве KernelArg
// Но к самой val1_ptr эти ограничения не применяются
var val1_ptr := @val1;
k.Exec1(1,
val1,
HFQ(()->val1_ptr^), // Захватили переменную val1_ptr, а не val1
// val1 нигде не захвачена, поэтому теперь так можно
@val1,
val1_ptr // То же самое
);
Выходить из подпрограммы, где объявили x
нельзя, пока .Exec
не закончит выполнятся.
Это так же значит, что возвращать очередь, содержащую KernelArg
из @x
обычно нельзя.
Но это можно обойти, если объявлять переменную в другой подпрограмме:
uses OpenCLABC;
var k: Kernel; // Вообще лучше передавать параметром в p2
function p2(var val: integer): CommandQueueBase;
begin
Result := k.NewQueue.AddExec1(1,
@val
);
end;
procedure p1;
begin
var val: integer;
var q := p2(val);
// Опять же, q не должна продолжать выпоняться
// после выхода из p1, потому что тут объявлена val
Context.Default.SyncInvoke(q);
end;
begin
p1;
end.
Тут val
объявлена в p1
. При этом val
в p2
является синонимом val
из p1
,
потому что объявлена var
-параметром - передачей по ссылке.
Если передать без var
перед параметром - val
из p2
будет копией,
а значит перестанет существовать после выхода из p2
.
Обратите внимание: Компилятор не заставит вас следовать описанным тут ограничениям.
И показанные на этой странице коды могут работать не смотря на эти ограничения,
потому что саму область памяти, на которую ссылается созданный KernelArg
, никто никогда не уничтожает.
Но в то же время эту же область памяти может использовать под другие данные, таким образом заменяя значение которое вы пытались передать в GPU на мусорные данные.
Это плохо в первую очередь потому, что на перезапись этих данных может повлиять совершенно не связанная часть кода, или различие архитектур компьтеров, таким образом усложняя поиск источника ошибки.
Поэтому, ещё раз, используйте передачу адреса в качестве KernelArg
только как тонкую оптимизацию и только когда понимаете что делаете.
OpenCL это неуправляемая библиотека. Обычно можно заставить управляеммые типы данных работать с ней.
Но часто это приводит к дополнительным затратам производительности.
И обычно не значит всегда - MemorySegment.ReadValue
, принимающее запись var
-параметром не может быть безопасным из за сборщика мусора
(и поэтому отсутствует).
Более прямым будет передача неуправляемых типов - указателей - без преобразований в подпрограммы модуля OpenCL
.
И эта возможность тоже существует, к примеру в виде MemorySegment.WriteData
. Но такие указатели ещё более не_безопасны:
Как минимум они требуют освобождения в try-finally
чтобы избежать утечек памяти.
И защиты от дурака, не возволяющей записать значение типа real
туда, где хранится int64
- не существует.
Как что-то среднее между этими двумя вариантами - существует NativeValue<T>
:
Этот класс является обёрткой указателя на область памяти RAM.
## uses OpenCLABC;
// В конструктор необходимо передавать значение,
// потому что иначе неуправляемая память будет содержать мусор
// Но можно передать default, чтобы заполнить выделяемую память нулями
var nv := new NativeValue<integer>(default(integer));
nv.Value := 5; // Перезаписываем значение,
nv.Value.Println; // И читаем назад
// Напрямую получать доступ к области памяти,
// через свойство Pointer, не рекомендуется
Writeln(nv.Pointer);
// Кроме как передавать nv.Pointer^ var-параметром
nv.Dispose; // Освобождение памяти - вызывается и само при сборке мусора
Всё преимущество GPU над CPU состоит в выполнении одинаковой программы на рое из процессорных ядер.
Каждое из этих ядер значительно медленнее ядер CPU, но используя весь рой вместе можно выполнять вычисления значительно быстрее.
Но раз все ядра GPU выполняют одну и ту же программу - им обычно приходится передавать массив из однотипных данных.
Объект типа CLArray<T>
(где T
- почти любая запись) является массивом, содержимое которого хранится в памяти GPU, для более быстрого доступа из кода на GPU.
CLArray<T>
создаётся конструктором:
## uses OpenCLABC;
// Массив для 10 элементов типа integer
var a1 := new CLArray<integer>(Context.Default, 10);
// Заполняем нулями, потому что при создании CLArray может содержать мусорные данные
a1.FillValue(0);
a1.GetArray.Println;
// Массив, в который при создании копируются данные из переданного массива
var a2 := new CLArray<byte>(Context.Default, new byte[](1,2,3,4,5,6,7));
a2.GetArray.Println;
// Массив, в который при создании копируется часть данных из переданного массива
// Точнее, копируется 2 элемента, начиная с элемента с индексом 3
var cpu_array := |0,1,2,3,4,5,6,7|;
var a3 := new CLArray<integer>(Context.Default, 3, 2, cpu_array);
a3.GetArray.Println;
Кроме того, объект контекста можно не передавать, тогда для выделения памяти будет автоматически выбран Context.Default
.
Элементы этого массива, так же как у обычных массивов, можно читать через индексное свойство:
## uses OpenCLABC;
var a := new CLArray<integer>(Context.Default, |0,1,4,9,16|);
a[3].Println;
a[4] := 5;
a[4].Println;
Но имейте в виду - каждое обращение к индексному свойству - это новое выполнение неявной очереди, поэтому заполнять или читать такой массив по 1 элементу не эффективно.
Лучше всего использовать полноценные очереди. Но если вы выполняете какое-то одноразовое заполнение - можно, хотя бы, использовать свойство .Section
:
## uses OpenCLABC;
var a := new CLArray<integer>(Context.Default, |0,1,4,9,16|);
// Заполняем элементы с индексами от 2 до 3 массивом |-1,-2|
a.Section[2..3] := |-1,-2|;
// Читаем и выводим элементы с 0 по 2 индексы как массив
a.Section[0..2].Println;
Некоторые данные, передаваемые в программы на GPU, могут быть общими (для всех ядер) или просто не однотипными.
Такие данные можно хранить в типе MemorySegment
, представляющем область памяти GPU, для которой не определено ничего кроме размера.
Область памяти MemorySegment
создаётся конструктором (new MemorySegment(...)
).
Основные конструкторы принимают размер области памяти и, опционально, контекст. Если контекст не указывается - память выделяется на Context.Default
.
Далее, в неё можно записывать и читать данные практически любого типа записи, в любой точке:
## uses OpenCLABC;
var s := new MemorySegment(sizeof(int64));
// $ Это запись числа в 16-ичной СС
s.WriteValue&<int64>($FEDCBA9876543210);
// .ToString('X') преобразовывает число в строку в 16-ичной СС
s.GetValue&<int64>.ToString('X').Println;
// Можно читать кусок данных одного типа, как данные другого типа
s.GetValue&<integer>.ToString('X').Println;
// Читаем начиная с четвёртого байта
// Я сделал 16-ичный вывод чтобы было видно, изначальное число разрезало пополам
s.GetValue&<integer>(4).ToString('X').Println;
// Ну и никто не запретит прочитать как совершенно не совместимый тип данных
// Но в ответ вы получите мусорные данные
s.GetValue&<real>.Println;
То есть, вам самим придётся заботится о типе внутренних данных, работая с MemorySegment
, но взамен вы полностью контролируете содержимое области памяти.
Память на GPU можно моментально освободить, вызвав метод MemorySegment.Dispose
.
Но вызывать его не обязательно, потому что этот метод вызывается автоматически во время сборки мусора.
Но кроме выделения памяти на GPU - OpenCL так же позволяет выделять память внутри другой области памяти.
Для этого используется тип MemorySubSegment
:
## uses OpenCLABC;
var c := Context.Default;
// Не обязательно MainDevice, можно взять любое устройство из контекста
var align := c.MainDevice.Properties.MemBaseAddrAlign;
var s := new MemorySegment(align*2, c);
// size может быть любым, но origin
// должно быть align*N, где N - целое
var s1 := new MemorySubSegment(s, 0, Min(123,align));
var s2 := new MemorySubSegment(s, align, align);
Writeln(s1);
Writeln(s2);
Передавать команды для GPU по одной не эффективно. Гораздо эффективнее передавать несколько команд сразу.
Для этого существуют очереди (типы, наследующие от CommandQueueBase
).
Они хранят произвольное количество команд для GPU.
А при необходимости также и части кода, выполняемые на CPU.
Все типы очередей наследует от CommandQueueBase
. Это значит что любую очередь можно сохранить в переменную типа CommandQueueBase
.
Но о значении типа CommandQueueBase
известно не на много больше чем о значении типа object
.
Так же, все очереди наследуют от одного из двух типов:
CommandQueueNil
- очередь возващающая nil
(именно нулевую ссылку, не пустое значение любого типа).CommandQueue<T>
(где T
- любой тип) - очередь возвращающая значение типа T
;После выполнения очереди CommandQueue<T>
метод Context.SyncInvoke
возвращает то, что вернула очередь.
А если использовать метод Context.BeginInvoke
- возвращаемое значение можно получить с помощью метода CLTask<T>.WaitRes
.
Результат других типов очередей нельзя получить, но их можно преобразовать к CommandQueue<T>
с произвольным T
с помощью .Cast
:
## uses OpenCLABC;
// Q объявлена как CommandQueueBase,
// а значит в неё можно сохранить любую очередь
var Q: CommandQueueBase;
// В данном случае сохраняем CommandQueueNil
Q := HPQ(()->Writeln('Q выполнилась'));
// Преобразовывать nil можно в любой ссылочный тип
Writeln(Context.Default.SyncInvoke( Q.Cast&<object> ));
// Exception тоже класс - поэтому можно и в него
// Но в результате всё равно получится nil
Writeln(Context.Default.SyncInvoke( Q.Cast&<Exception> ));
Sleep(1000); // Чтобы было видно предыдущий вывод
//Ошибка времени выполнения: .Cast не может преобразовывать nil в integer
// Ошибка кидается ещё в момент создания .Cast очереди
Writeln(Context.Default.SyncInvoke( Q.Cast&<integer> ));
Подробнее тут.
В то же время, если результат выполнения очереди не нужен,
от него можно избавится с помощью .DiscardResult
.
Это может понадобится если вы хотите:
CommandQueueNil
;## uses OpenCLABC;
var Q := HFQQ(()->
begin
Writeln('Функция выполнилась');
Result := 5;
end).DiscardResult;
var t := Context.Default.BeginInvoke(Q as CommandQueueBase);
$'Has result: {not (t is CLTaskNil)}'.Println;
t.Wait;
Без .DiscardResult
- BeginInvoke
создаст CLTask<integer>
,
не смотря на то, что в него передают CommandQueueBase
.
А с .DiscardResult
- HFQQ
выполняется как будто это HPQQ
.
Обратите внимание, если результат очереди выкидывается, без какого-либо использования - ресурсы на него не выделяются. К примеру:
## uses OpenCLABC;
Context.Default.SyncInvoke(
HFQQ(()->1) +
HFQQ(()->2)
);
Первая HFQQ
выполнится как будто это HPQQ
, потому что
из SyncInvoke
можно получить только результат второй HFQQ
.
Очереди, созданные из областей памяти OpenCL или kernel'ов возващают свои области памяти/Kernel
'ы соответственно, из которых были созданы;
Очереди, созданные с HF[Q]Q
- значение, которое вернёт переданная функция;
Очереди, созданные с HP[Q]Q
являются CommandQueueNil
.
Демонстрация:
## uses OpenCLABC;
/// Вывод типа и значения объекта
procedure OtpObject(o: object) :=
$'{if o=nil then nil else TypeName(o)}[{_ObjectToString(o)}]'.Println;
// _ObjectToString это функция, которую используют
// Writeln и Println для форматирования значений
procedure Test(q: CommandQueueBase) :=
OtpObject(Context.Default.SyncInvoke(
// Преобразовываем результат к object, чтобы его вернула SyncInvoke
q.Cast&<object>
));
var s := new MemorySegment(1);
// Тип - MemorySegment, потому что очередь создали из него
Test( s.NewQueue );
// Тип - integer, потому что это тип по умолчанию для выражения (5)
Test( HFQQ(()->5) );
// Тип - string, по той же причине
Test( HFQQ(()->'abc') );
// Тип отсутствует, потому что HP[Q]Q возвращает nil
Test( HPQQ(()->Print('Выполнилась HPQQ:')) );
Проверить что очередь ничего не возвращает очень просто:
var Q: CommandQueueBase;
...
if Q is CommandQueueNil(var cqn) then
p1(cqn) else
p2(Q);
Но для типа CommandQueue<T>
надо указать конкретный тип, чтобы вызвать is
.
Другими словами, с помощью is
можно проверять только по одному типу возвращаемого значения за раз:
var Q: CommandQueueBase;
...
if Q is CommandQueueNil(var cqn) then
p1(cqn) else
if Q is CommandQueue<byte>(var cq) then
p2&<byte>(cq) else
if Q is CommandQueue<word>(var cq) then
p2&<word>(cq) else
// Не должно происходить
raise new System.NotSupportedException;
Если надо вызвать p2
для очереди с любым возвращаемым значением - используется .UseTyped
:
uses OpenCLABC;
procedure p1(cq: CommandQueueNil) := Writeln('nil');
procedure p2<T>(cq: CommandQueue<T>) := Writeln($'<{TypeToTypeName(typeof(T))}>');
type
// Не обязательно запись
TypedUser = record(ITypedCQUser)
public procedure UseNil(cq: CommandQueueNil) := p1(cq);
public procedure Use<T>(cq: CommandQueue<T>) := p2(cq);
end;
procedure Test(Q: CommandQueueBase) :=
Q.UseTyped(new TypedUser);
begin
Test(HPQ(()->begin end));
Test(HFQ(()->0));
Test(HFQ(()->0.0));
end.
Объявлять дополнительный тип (TypedUser
в этом коде) необходимо потому, что иначе
передать подпрограмму Use<T>
, не указывая её <T>
, в UseTyped
не получится.
Так же, если нужно не только использовать очередь, но и что-то вернуть - используется .ConvertTyped
:
uses OpenCLABC;
type
// Получает имя типа результата очереди, или nil если он отсутствует
QueueConverterResTName = record(ITypedCQConverter<string>)
public function ConvertNil(cq: CommandQueueNil): string := nil;
public function Convert<T>(cq: CommandQueue<T>): string := TypeToTypeName(typeof(T));
end;
procedure Test(Q: CommandQueueBase) :=
Writeln( Q.ConvertTyped(new QueueConverterResTName) );
begin
Test(HPQ(()->begin end));
Test(HFQ(()->0));
Test(HFQ(()->0.0));
end.
И .UseTyped
и .ConvertTyped
гарантируют что обязательно будет вызван ровно один
из двух методов - либо принимающий CommandQueueNil
, либо принимающий CommandQueue<T>
.
Самый простой способ выполнить очередь - вызвать метод Context.SyncInvoke
.
У него есть три перегрузки, для CommandQueueBase
, CommandQueueNil
и CommandQueue<T>
.
Только последняя возвращает результат.
Но если надо выполнить очередь асинхронно - лучше использовать метод Context.BeginInvoke
,
потому что его всё равно вызывает Context.SyncInvoke
.
Context.BeginInvoke
запускает асинхронное выполнение очереди.
Как только очередь была полностью запущена он возвращает объект типа
CLTaskBase
, CLTaskNil
или CLTask<T>
для соответствующих типов очередей.
Так же как в случае очередей, CLTaskNil
и CLTask<T>
наследуют от CLTaskBase
.
У всех CLTask
-ов есть:
.OrgContext
и .OrgQueue
, возвращающие контекст выполнения и выполняемую очередь соответственно..Wait
для ожидания окончания выполнения очереди.У CLTask<T>
так же есть метод .WaitRes
, вызывающий .Wait
и затем возвращающий результат очереди.
При выполнении очереди может произойти несколько исключений, поэтому, чтобы получить можно было все, их упаковывает в System.AggregateException
:
## uses OpenCLABC;
try
// Context.SyncInvoke или CLTask.Wait
Context.Default.SyncInvoke(Q);
except
on e: System.AggregateException do
e.InnerExceptions.PrintLines;
end;
Но для более тонкого контроля лучше использовать
.Handle*
методы очередей.
OpenCL используется для передачи комманд в GPU.
Поэтому в первую очередь стоит поговорить об очередях OpenCLABC
, содержащих комманды для GPU.
Такие очереди создаются из некоторых типов-простых_обёрток методом .NewQueue
и имеют тип с припиской CCQ
.
("Command Container Queue", то есть очередь-контейнер для коман GPU)
К примеру, метод CLArray<byte>.NewQueue
вернёт очередь типа CLArrayCCQ<byte>
, наследующего от CommandQueue< CLArray<byte> >
.
Чтобы создать саму комманду - надо вызвать соответствующий ей метод CCQ
объекта. К примеру:
## uses OpenCLABC;
// Массив на 3 элемента типа integer
var a := new CLArray<integer>(3);
// Создаём очередь
var q: CLArrayCCQ<integer> := a.NewQueue;
Context.Default.SyncInvoke(q
.ThenWriteValue(1, 0)
// Записывать по 1 элементу не эффективно
// Лучше сначала создать массив в RAM
// А затем послать его целиком
.ThenWriteArray(|5,7|, 1, 2,0)
// .ThenGet методы особенные, потому что не возвращают CCQ объект
// В данном случае эта комманда читает весь CLArray как обычный массив в RAM
.ThenGetArray
).Println;
Также, CCQ
очереди можно создавать из очередей, возвращающих объект с командами. Для этого используется конструктор:
var q0: CommandQueue<MemorySegment>;
...
var q := new MemorySegmentCCQ(q0);
Команды объектов, представляющих память на GPU, можно разделить на группы.
По направлению передачи:
Write
и Fill
: Из RAM в память GPU;Read
и Get
: Из памяти GPU в RAM;Copy
: Между двумя областями памяти GPU.И по типу данных на стороне RAM:
Data
: Используются данные, находящиеся в RAM по указанному адресу;Value
: Используется размерное значение;Array
: Используется содержимое указанного массива размерных значений.Но при этом отсутствуют некоторые комбинации.
В первую очередь, в случае Copy
нет понятия типа данных на стороне RAM, потому что RAM в принципе не задействуется.
И нет GetData
, потому что в случае ошибок будут утечки памяти.
Вместо него выделяйте память явно и передавайте её в ReadData
.
Так же, WriteValue
может принимать размерное значение и NativeValue
, но ReadValue
принимает только NativeValue
.
Это потому, что принимать размерное значение в ReadValue
var
-параметром не безопасно,
как и в случае передачи адреса в качестве KernelArg
.
Если вы понимаете что делаете - используйте ReadData
, явно передавая в него адрес вашего значения (то есть указатель).
Но обычно лучше использовать GetValue
, создающее и возвращающее новое размерное значение, либо ReadValue
принимающее NativeValue
.
Кроме таких объектов, методы-команды для GPU есть только у Kernel
. И все они представляют запуск kernel'а.
Комманда .ThenGet
не выполняется, если её результат выкидывается.
Подробнее тут.
Это оптимизация, которую вы можете заметить только в одном случае: Если параметры этой команды были неправильные - ошибка не возникнет.
Константы в любом контексте позволяют проводить особые оптимизации.
В случае OpenCLABC
- константные очереди являются константами только на время выполнения очереди.
## uses OpenCLABC;
var cq := new ConstQueue<integer>(1);
Context.Default.SyncInvoke(cq).Println;
cq.Value := 2;
Context.Default.SyncInvoke(cq).Println;
Обратите внимание, .Value
нельзя изменять пока .BeginInvoke
не досоздаст CLTask
. Иначе поведение неопределено.
Так же, константную очередь можно создать присвоив значение результата туда, где ожидается очередь:
var q: CommandQueue<integer> := 1;
Для этого тип значения (1
) и результата очереди <integer>
должны полностью совпадать. К примеру так сделать не позволит:
//Ошибка: Нельзя преобразовать тип integer к CommandQueue<object>
var q: CommandQueue<object> := 1;
Чтобы можно было присвоить - значение надо явно преобразовать к object
:
var q: CommandQueue<object> := object(1);
Этот способ создания константной очереди обычно используется подпрограммами, принимающими очереди:
## uses OpenCLABC;
procedure p1(q: CommandQueue<integer>) :=
Context.Default.SyncInvoke(q).Println;
p1(1);
p1(HFQQ(()->2));
В OpenCLABC
есть множество подпрограмм, принимающий очередь вместо значения.
Подробнее.
Если нужно сохранить константную очередь в переменную, можно написать:
var q1 := new ConstQueue<integer>(1);
var q2: CommandQueue<integer> := 3;
Но оба способа объёмны и, что важнее, требуют указать тип значения явно.
Поэтому в OpenCLABC
есть вспомогательная подпрограмма:
var q3 := CQ(3);
У очередей есть возвращаемое значение, но чтобы передавать в них данные при запуске - можно или создавать новую очередь на каждое выполнение (что очень не эффективно), или выкручиваться так:
uses OpenCLABC;
type
MyQueueContainer = sealed class
private Q: CommandQueue<integer>;
private par1 := new ConstQueue<integer>(-1);
private par2 := new ConstQueue<string>(nil);
// Эта очередь ничего полезного не делает, но это только пример
public constructor := self.Q :=
MemorySegment.Create(sizeof(integer)).NewQueue
.ThenWriteValue( self.par1 )
.ThenQueue( self.par2.ThenQuickUse(x->Writeln(x)) )
.ThenGetValue&<integer>;
public function Invoke(par1: integer; par2: string): integer;
begin
var tsk: CLTask<integer>;
// Нужна блокировка, чтобы если метод Invoke будет выполнен
// в нескольких потоках одновременно, .Value параметров
// не могло поменяться пока Context.BeginInvoke создаёт CLTask
lock self do
begin
self.par1.Value := par1;
self.par2.Value := par2;
tsk := Context.Default.BeginInvoke(Q);
end;
Result := tsk.WaitRes;
end;
end;
begin
var cont := new MyQueueContainer;
cont.Invoke(1, 'abc').Println;
cont.Invoke(2, 'def').Println;
end.
Но это не очень красиво. Можно сделать красивее, используя специальную очередь-параметр.
ParameterQueue<T>
тоже является константной во время выполнения очереди, но используется по-другому:
uses OpenCLABC;
type
MyQueueContainer = sealed class
private Q: CommandQueue<integer>;
private par1 := new ParameterQueue<integer>('par1');
private par2 := new ParameterQueue<string>('par2');
// Эта очередь ничего полезного не делает, но это только пример
public constructor := self.Q :=
MemorySegment.Create(sizeof(integer)).NewQueue
.ThenWriteValue( self.par1 )
.ThenQueue( self.par2.ThenQuickUse(x->Writeln(x)) )
.ThenGetValue&<integer>;
public function Invoke(par1: integer; par2: string) :=
Context.Default.SyncInvoke(self.Q,
self.par1.NewSetter(par1),
self.par2.NewSetter(par2)
);
end;
begin
var cont := new MyQueueContainer;
cont.Invoke(1, 'abc').Println;
cont.Invoke(2, 'def').Println;
end.
Таким образом код получается значительно чище.
Кроме того, при запуске очереди проводятся проверки, чтобы очереди-параметры
не оказывались не_определены или установлены дважды.
Иногда между командами для GPU надо вставить выполнение обычного кода на CPU. А разрывать для этого очередь на две части - плохо, потому что одна целая очередь всегда выполнится быстрее двух её частей.
Поэтому существует множество типов очередей, хранящих обычный код для CPU.
Чтобы создать самую простую такую очередь используются глобальные подпрограммы HFQ
и HPQ
:
HFQ — Host Function Queue
HPQ — Host Procedure Queue
(Хост в контексте OpenCL - это CPU, потому что с него посылаются команды для GPU)
Они возвращают очередь, выполняющую код (функцию/процедуру соотвественно) на CPU.
Пример применения приведён на странице выше.
Так же бывает нужно использовать результат предыдущей очереди в коде на CPU.
Для этого используются методы .ThenUse
и .ThenConvert
:
## uses OpenCLABC;
var Q := HFQ(()->5);
Context.Default.SyncInvoke(Q
.ThenUse(x->Println($'x*2 = {x*2}'))
.ThenConvert(x->$'x^2 = {x**2}')
).Println;
.ThenUse
дублирует возвращаемое значение предыдущей очереди (Q
в примере).
А .ThenConvert
возвращает результат выполнения переданной функции, как и HFQ
.
OpenCLABC
очереди существуют чтобы можно было удобно описывать параллельные процедуры.
Поэтому, с расчётом на параллельность, обычные очереди с кодом для CPU создают себе по одному потоку выполнения (Thread
) при запуске.
Этот поток выполнения запускается до выхода из Context.BeginInvoke
и остаётся в режиме ожидая.
Но даже если игнорировать затраты на запуск потока, выход из режима ожидания это не моментальня операция.
Если надо выполнить очень простое действие, как в последнем примере выше, эти затраты неоправданны.
Для таких случаев используется Quick
версии очередей:
## uses OpenCLABC;
var Q := HFQQ(()->5);
Context.Default.SyncInvoke(Q
.ThenQuickUse(x->Println($'x*2 = {x*2}'))
.ThenQuickConvert(x->$'x^2 = {x**2}')
).Println;
В отличии от предыдущего примера, в данном будет создан только один поток выполнения (его всегда создаёт Context.BeginInvoke
).
В общем случае Quick
очереди стараются выполняться на одном из уже существующих потоков выполнения, но так чтобы не нарушать порядок выполнения очередей.
В случае HFQ
и HPQ
, их Quick
варианты это HFQQ
и HPQQ
соответственно.
Если вам необходимо быстро преобразовать тип возвращаемого значения очереди - можно использовать .ThenQuickConvert
:
## uses OpenCLABC;
var Q := HFQQ(()->1 as object);
Context.Default.SyncInvoke(
Q.ThenQuickConvert(x->integer(x))
).Println;
Но в OpenCLABC
для случая простого преобразования существует особо-оптимизированный метод .Cast
.
Он ограничен примерно так же, как метод последовательностей .Cast
. То есть:
uses OpenCLABC;
type t1 = class end;
type t2 = class(t1) end;
begin
var Q1: CommandQueue<integer> := 5;
var Q2: CommandQueueBase := Q1;
var Q3: CommandQueue<t1> := (new t2) as t1;
var Q4: CommandQueue<t1> := new t1;
var Q5: CommandQueue<t2> := new t2;
// Можно, потому что к object можно преобразовать всё
Context.Default.SyncInvoke( Q1.Cast&<object> );
//Ошибка: .Cast не может преобразовывать integer в byte
// Преобразование записей меняет представление данных в памяти
// Можно преобразовывать только object в запись и назад
// Context.Default.SyncInvoke( Q1.Cast&<byte> );
// Можно, Q2 и так имеет тип CommandQueue<integer>,
// а значит тут Cast вернёт (Q2 as CommandQueue<integer>)
Context.Default.SyncInvoke( Q2.Cast&<integer> );
// Можно, потому что Q3 возвращает t2
Context.Default.SyncInvoke( Q3.Cast&<t2> );
//Ошибка: Не удалось привести тип объекта "t1" к типу "t2".
// Q4 возвращает не t2 а именно t1
// Context.Default.SyncInvoke( Q4.Cast&<t2>.HandleDefaultRes(e->e.Message.Println<>nil, new t2) );
// Можно, потому что t2 наследует от t1
Context.Default.SyncInvoke( Q5.Cast&<t1> );
end.
Кроме того, .Cast
можно применять к очередям без типа результата:
## uses OpenCLABC;
var Q1 := HPQQ(()->begin end);
var Q2 := HFQQ(()->5) as CommandQueueBase;
// .Cast применимо к CommandQueueNil, но результат будет всегда nil
Writeln( Context.Default.SyncInvoke(Q1.Cast&<object>) );
// .Cast применимо и к CommandQueueBase
Writeln( Context.Default.SyncInvoke(Q2.Cast&<object>) );
Ближайшей альтернативой к вызову CommandQueueBase.Cast
будет вызов .ConvertTyped
- но для этого надо на много больше кода.
Основная оптимизация .Cast
состоит в том, что преобразование не выполняется, если возможно.
Но будьте осторожны, в некоторых случаях такая оптимизация приглушит ошибку:
uses OpenCLABC;
type t1 = class end;
type t2 = class(t1) end;
begin
Context.Default.SyncInvoke( HFQ(()->new t1).Cast&<t2>.Cast&<t1> );
end.
Преобразование в t2
не выполняется из-за следующего .Cast
, поэтому
проверяется только преобразование defalt(t1)
к t2
(во время создания очереди) - а оно допустимое.
Если сложить две очереди A и B (var C := A+B
) — получится очередь C, в которой сначала выполнится A, а затем B.
Очередь C будет считаться выполненной тогда, когда выполнится очередь B.
Если умножить две очереди A и B (var C := A*B
) — получится очередь C, в которой одновременно начнут выполняться A и B.
Очередь C будет считаться выполненной тогда, когда обе очереди (A и B) выполнятся.
Как и в математике, умножение имеет бОльший приоритет чем сложение.
В обоих случаях очередь C будет возвращать то, что вернула очередь B. То есть если складывать и умножать много очередей - результат будет всегда возвращать то, что вернула самая последняя очередь.
Простейший пример:
## uses OpenCLABC;
var q1 := HPQ(()->
begin
// lock необходим чтобы при параллельном выполнении два потока
// не пытались использовать вывод одновременно. Иначе выведет кашу
lock output do Writeln('Очередь 1 начала выполняться');
Sleep(500);
lock output do Writeln('Очередь 1 закончила выполняться');
end);
var q2 := HPQ(()->
begin
lock output do Writeln('Очередь 2 начала выполняться');
Sleep(500);
lock output do Writeln('Очередь 2 закончила выполняться');
end);
Writeln('Последовательное выполнение:');
Context.Default.SyncInvoke( q1 + q2 );
Writeln;
Writeln('Параллельное выполнение:');
Context.Default.SyncInvoke( q1 * q2 );
Операторы += и *= также применимы к очередям.
И как и для чисел - A += B
работает как A := A+B
(и аналогично с *=).
А значит, возвращаемые типы очередей A и B должны быть одинаковыми, чтобы к ним можно было применить +=/*=.
Если надо сложить/умножить много очередей - лучше применять CombineSyncQueue
/CombineAsyncQueue
соответственно.
Точнее A+B+C
это то же самое что CombineSyncQueue(CombineSyncQueue(A, B), C)
.
Поэтому CombineSyncQueue(A, B, C)
создаст очередь немного быстрее чем A+B+C
.
Но скорость выполнения очереди будет абсолютно одинаковой в этих двух случаях.
Чтобы складывать/умножать много CommandQueueBase
и CommandQueueNil
очередей
используются Combine*QueueBase
и Combine*QueueNil
соответственно.
Используя CombineConv*Queue
можно указать функцию преобразования, принимающую результаты всех скомбинированных очередей:
## uses OpenCLABC;
var q1 := HFQ( ()->1 );
var q2 := HFQ( ()->2 );
// Выводит 2, то есть только результат последней очереди
// Так сделано из за вопросов производительности
Context.Default.SyncInvoke( q1+q2 ).Println;
// Но бывает так, что нужны результаты всех сложенных/умноженных очередей
// В таком случае надо использовать CombineConv*Queue
Context.Default.SyncInvoke(
CombineConvSyncQueue(
results->results.JoinToString, // Функция преобразования
q1, q2
)
).Println;
// Выводит строку "1 2". Это то же самое, что вернёт "Arr(1,2).JoinToString"
И так же как с другими очередями с кодом на CPU - есть Quick
версия:
## uses OpenCLABC;
var q1 := HFQQ( ()->1 );
var q2 := HFQQ( ()->2 );
Context.Default.SyncInvoke( q1+q2 ).Println;
Context.Default.SyncInvoke(
CombineQuickConvSyncQueue(
results->results.JoinToString,
q1, q2
)
).Println;
Если надо скомбинировать несколько очередей с разными типами результатов, но при этом использовать все результаты, можно использовать CombineConv*QueueN*
:
## uses OpenCLABC;
var q1 := HFQQ( ()->1 );
var q2 := HFQQ( ()->'abc' );
Context.Default.SyncInvoke( q1+q2 ).Println;
Context.Default.SyncInvoke(
CombineConvSyncQueueN2(
(r1,r2)->$'integer[{r1}]+string[{r2}]',
q1, q2
)
).Println;
У каждой из CombineConv*QueueN*
так же есть CombineQuickConv*QueueN*
версия.
В данный момент всё ещё не работает... Но уже совсем скоро, правда-правда!
Одну и ту же очередь можно использовать несколько раз, в том числе одновременно:
## uses OpenCLABC;
var Q := HPQQ(()->lock output do Writeln('Q выполнилась'));
var t1 := Context.Default.BeginInvoke(Q);
var t2 := Context.Default.BeginInvoke(Q*Q);
t1.Wait;
t2.Wait;
Но эта программа выведет "Q выполнилась" три раза, потому что при каждом упоминании - Q запускается ещё раз.
Это не всегда хорошо. К примеру, может быть так что Q
содержит какой то затратный алгоритм. Или ввод значения с клавиатуры:
## uses OpenCLABC;
var Q := HFQ(()->
begin
lock input do
Result := ReadInteger;
end);
Context.Default.SyncInvoke(CombineConvAsyncQueue(res->res,
Q,
Q.ThenQuickConvert(i->i*i),
Q.ThenQuickConvert(i->i*i*i)
)).Println;
Эта программа запросит три разных значения, что не всегда то что надо.
Чтобы использовать результат одной очереди несколько раз применяется .Multiusable
:
## uses OpenCLABC;
var Q := HFQ(()->
begin
lock input do
Result := ReadInteger;
end);
var Qs := Q.Multiusable;
// Теперь нет смысла в Async, потому что
Context.Default.SyncInvoke(CombineConvAsyncQueue(res->res,
Qs(),
Qs().ThenQuickConvert(i->i*i),
Qs().ThenQuickConvert(i->i*i*i)
)).Println;
.Multiusable
создаёт новую функцию, вызывая которую можно получить любое количество очередей,
у которых будет общий результат.
Каждый вызов .Multiusable
создаёт именно новую функцию.
Это значит, что если использовать результаты двух вызовов .Multiusable
- исходная очередь выполнится два раза:
## uses OpenCLABC;
var Q := HFQ(()->
begin
lock input do
Result := ReadInteger;
end);
var Q1s := Q.Multiusable;
var Q2s := Q.Multiusable;
Context.Default.SyncInvoke(CombineConvAsyncQueue(res->res,
Q1s(),
Q1s().ThenQuickConvert(i->i*i),
Q2s().ThenQuickConvert(i->i*i*i)
)).Println;
Очередь для которой вызвали .Multiusable
начинает выполняться не ожидая другие очереди:
## uses OpenCLABC;
var Q0 := HPQ(()->
begin
Sleep(1000);
Writeln('Q0');
end);
var Q1s := HPQ(()->
begin
Writeln('Q1');
end).Multiusable;
var Q1 := Q1s();
// Q1 выполнит первым
Context.Default.SyncInvoke( Q0 + Q1 );
Чтобы Q1
выполнялась после Q0
- они должны быть сумированны под .Multiusable
:
## uses OpenCLABC;
var Q0 := HPQ(()->
begin
Sleep(1000);
Writeln('Q0');
end);
var Q1 := HPQ(()->
begin
Writeln('Q1');
end);
var Qs := (Q0+Q1).Multiusable;
Context.Default.SyncInvoke( Qs() );
.Multiusable
не работает между вызовами Context.BeginInvoke
:
## uses OpenCLABC;
var Q := HFQ(()->
begin
lock input do
Result := ReadInteger;
end);
var Qs := Q.Multiusable;
Context.Default.SyncInvoke( Qs() ).Println;
Context.Default.SyncInvoke( Qs().ThenQuickConvert(i->i*i) ).Println;
Эта программа запросит ввод два раза.
Если контекст у двух очередей общий - лучше объединить вызовы Context.BeginInvoke
.
Так не только .Multiusable
будет работать, но и выполнение будет в целом быстрее.
А если контекст разный - надо сохранять результат в переменную и использовать Wait
очереди
(подробнее на странице ниже).
Между командами в CCQ
очередях бывает надо вставить выполнение другой очереди или кода для CPU.
Это можно сделать, используя несколько .NewQueue
:
var s: MemorySegment;
var q0: CommandQueueBase;
...
var q :=
s.NewQueue.ThenWriteValue(...) +
q0 +
HPQ(...) +
HPQQ(...) +
s.NewQueue.ThenWriteValue(...)
;
Однако можно сделать и красивее:
var s: MemorySegment;
var q0: CommandQueueBase;
...
var q := s.NewQueue
.ThenWriteValue(...)
.ThenQueue(q0)
.ThenProc(...)
.ThenQuickProc(...)
.ThenWriteValue(...)
;
(На самом деле процедура передаваемая в .ThenProc
'а принимает исходную простую обёртку параметром, что делает его более похожим на .ThenUse
, чем HPQ
)
Эти методы не имеют незаменимых применений, но позволяют сделать код значительно читабельнее.
Большинство деревьев выполнения очередей можно реализовать используя только сложение и умножение очередей. Но есть несколько проблем:
Большинство но не все. Пример дерева которое нельзя реализовать через сложение и умножение можно найти тут или в файле:
C:\PABCWork.NET\Samples\OpenGL и OpenCL\OpenCLABC\Wait очереди\1.pas
Даже если дерево реализуется сложением+умножением - такая реализация может выглядеть запутано и не похоже на график дерева выполнения, к примеру:
C:\PABCWork.NET\Samples\OpenGL и OpenCL\OpenCLABC\Wait очереди\2.pas
Очереди, выполняемые на разных контекстах не могут эффективно взаимодействовать.
Эти проблемы решают Wait
-очереди:
## uses OpenCLABC;
var M := WaitMarker.Create;
var t := Context.Default.BeginInvoke(
HPQ(()->Writeln('Начало .BeginInvoke')) +
WaitFor(M) +
HPQ(()->Writeln('Конец .BeginInvoke'))
);
Sleep(2000);
Writeln('Посылаем сигнал маркера M');
M.SendSignal;
t.Wait;
WaitFor(M)
создаёт очень простую Wait
-очередь.
Внутри вызова .BeginInvoke
(до того как он вернёт CLTask
)
эта очередь входит в режим ожидания сигналов от маркера M
.
В этом режиме она не тратит время процессора, но готова возобновить выполнение в любой момент.
Далее, вызов M.SendSignal
посылает по 1 сигналу всем .BeginInvoke
, имеющим Wait
-очереди в ожидании этого сигнала.
WaitMarker
не является очередью, но может быть преобразован к типу CommandQueueBase
, в своеобразную выполняемую форму.
Преобразование к выполняемой форме обычно происходит автоматически, если складывать/умножать его с другими очередями,
либо передавать в подпрограмму принимающую CommandQueueBase
, как .BeginInvoke
.
Так же его можно вызвать явно, написав CommandQueueBase(M)
, где M
- маркер.
Выполняемая форма маркера вызывает метод .SendSignal
своего макера.
То есть CommandQueueBase(M)
обычно равноценна, но немного эффективнее чем HPQQ(M.SendSignal)
.
Но не всегда - потому что HPQ
возвращает nil
, а выполняемая форма маркера может возвращать другие результаты (об этом ниже).
И стоит так же сказать, прямой вызов M.SendSignal
всё равно всегда эффективнее чем Context.Default.SyncInvoke(M)
.
Используйте выполнение маркеров внутри .BeginInvoke
только если вам надо активировать его сразу после других очередей:
var M := WaitMarker.Create;
Context.Default.SyncInvoke(
HFQ(()->5) + M
);
Но в этом же коде видно ещё одну проблему - сложение очередей возвращает последний результат,
то есть результат маркера (который для простого макера из WaitMarker.Create
- nil
).
Если нужно иметь сразу и маркер и возвращаемое значение предыдущей очереди,
можно создать оторванный сигнал маркера методом .ThenMarkerSignal
:
## uses OpenCLABC;
var Q := HFQ(()->5).ThenMarkerSignal;
var t := Context.Default.BeginInvoke(
WaitFor(Q) + HPQ(()->Writeln('Получен сигнал от Q'))
);
var res := Context.Default.SyncInvoke( Q );
t.Wait;
res.Println;
Тут выполнение Q
сначала выполнит HFQ
, затем пошлёт сигнал в
Wait
-очередь WaitFor(Q)
и в конце вернёт то, что вернула HFQ
.
В общем случае, оторванный сигнал макера является очередью, и имеет то же возвращаемое значение, что очередь из которой он был создан.
Но в то же время его можно преобразовать типу WaitMarker
, так же как WaitMarker
преобразовывается в CommandQueueBase
.
Более того, выполняемая форма полученного маркера - это оторванный сигнал, из которого создали этот маркер.
Другими словами даже после преобразования в WaitMarker
- оторванный сигнал можно выполнить и получить его результат:
## uses OpenCLABC;
var Q := HFQ(()->5).ThenMarkerSignal;
var M: WaitMarker := Q;
Context.Default.SyncInvoke(
CommandQueueBase(M)
// CommandQueueBase надо всё равно как то преобразовать,
// чтобы SyncInvoke вернуло результат Q
.Cast&<integer>
).Println;
Того же эффекта можно добится используя .Multiusable
:
## uses OpenCLABC;
var Qs := HFQ(()->5).Multiusable;
var M := WaitMarker.Create;
var t := Context.Default.BeginInvoke(
WaitFor(M) + HPQ(()->Writeln('Получен сигнал от M'))
);
var res := Context.Default.SyncInvoke( Qs()+M+Qs() );
t.Wait;
res.Println;
То есть .ThenMarkerSignal
не имеет незаменимых применений, но делает код читабельнее.
Есть всего три подпрограммы, создающие Wait
-очереди:
Глобальная, WaitFor
:
Ничего не делает сама, но блокирует выполнение, ожидая сигнала указанного маркера.
Ещё один общий метод CCQ объектов: .ThenWait
:
Как и .ThenQueue
и .ThenProc
, .ThenWait(...)
это всего лишь аналог
.ThenQueue(WaitFor(...))
, существующий только ради читабельности кода.
Метод очереди с результатом, .ThenWaitFor
:
Как и .ThenMarkerSignal
, сначала выполняет исходную очередь, потом ожидает и в конце возвращает результат исходной очереди.
Маркеры можно комбинировать:
## uses OpenCLABC;
var M1 := WaitMarker.Create;
var M2 := WaitMarker.Create;
var Q1 := WaitFor(M1 and M2); // Ожидание обоих маркеров
var Q2 := WaitFor(M1 or M2); // Ожидание любого из маркеров
При этом если надо применить and
или or
>2 маркерам - лучше использовать WaitAll
/WaitAny
соответственно:
## uses OpenCLABC;
var M1 := WaitMarker.Create;
var M2 := WaitMarker.Create;
var M3 := WaitMarker.Create;
var Q1 := WaitFor(WaitAll(M1,M2,M3)); // Ожидание всех маркеров
var Q2 := WaitFor(WaitAny(M1,M2,M3)); // Ожидание любого из маркеров
Wait
-очереди работают даже между вызовами Context.BeginInvoke
, в отличии от всего остального в OpenCLABC
.
Это не всегда безопасно:
Context.Default.BeginInvoke(M);
Context.Default.BeginInvoke(WaitFor(M) + Q);
Проблема этого кода в том, что M
может послать сигнал ещё до того как WaitFor(M)
начнёт ждать.
Чтобы такое не происходило - надо всегда запускать Wait
-очередь раньше маркера:
Context.Default.BeginInvoke(WaitFor(M) + Q);
Context.Default.BeginInvoke(M);
Но, как всегда, лучше объединять вызовы Context.BeginInvoke
:
Context.Default.BeginInvoke(
( M ) *
( WaitFor(M) + Q )
);
Все Wait
-очереди начинают ждать в самом начале вызова Context.BeginInvoke
, перед началом выполнения очереди.
Поэтому если Wait
-очередь и вызов её маркера находятся в общем Context.BeginInvoke
- использовать их можно в любом порядке.
Все Wait
-очереди в одном Context.BeginInvoke
, ожидающие один и тот же маркер, образуют общую Wait
-группу.
Когда ожидаемый этой группой маркер активируется - он удаляет из Wait
-группы одну из Wait
-очередей, посылая ей сигнал.
## uses OpenCLABC;
var Q1 := HPQ(()->
begin
Sleep(10);
lock output do Writeln('Выполнилась Q1');
end).ThenMarkerSignal;
var Q2 := HPQ(()->lock output do Writeln('Выполнилась Q2'));
var Q3 := HPQ(()->lock output do Writeln('Выполнилась Q3'));
var t1 := Context.Default.BeginInvoke(
(WaitFor(Q1)+Q2) *
(WaitFor(Q1)+Q3)
);
Context.Default.SyncInvoke(Q1+Q1);
// Все оставшиеся CLTask лучше ожидать, чтобы
// вывести ошибки, если возникнут при выполнении очереди
t1.Wait;
Тут Q1
посылает 2 сигнала, сначала в первый WaitFor(Q1)
, затем во второй.
В данный момент не рекомендуется расчитывать на порядок Wait
-очередей в Wait
-группе.
Ну и, конечно, лучше совместить вызовы Context.BeginInvoke
, раз контекст общий:
## uses OpenCLABC;
var Q1 := HPQ(()->
begin
Sleep(10);
lock output do Writeln('Выполнилась Q1');
end).ThenMarkerSignal;
var Q2 := HPQ(()->lock output do Writeln('Выполнилась Q2'));
var Q3 := HPQ(()->lock output do Writeln('Выполнилась Q3'));
Context.Default.SyncInvoke(
(Q1+Q1) *
(WaitFor(Q1)+Q2) *
(WaitFor(Q1)+Q3)
);
Будьте осторожны, лишняя Wait
-очередь вызовет зависание:
## uses OpenCLABC;
var Q1 := HFQ(()->0).ThenMarkerSignal;
var t1 := Context.Default.BeginInvoke(
WaitFor(Q1) +
// Второй запуск Q1 никогда не произойдёт,
// поэтому эта Wait очередь зависнет
WaitFor(Q1)
);
Context.Default.SyncInvoke(Q1);
t1.Wait;
Wait
-очереди ожидающие один и тот же маркер в разных Context.BeginInvoke
- образуют отдельные Wait
-группы.
И при активации маркера - он посылает по 1 сигналу каждой (ожидающей его) Wait
-группе:
## uses OpenCLABC;
var Q1 := HPQ(()->
begin
Sleep(1000);
lock output do Writeln('Выполнилась Q1');
end).ThenMarkerSignal;
var Q2 := HPQ(()->lock output do Writeln('Выполнилась Q2'));
var Q3 := HPQ(()->lock output do Writeln('Выполнилась Q3'));
var Q4 := HPQ(()->lock output do Writeln('Выполнилась Q4'));
var Q5 := HPQ(()->lock output do Writeln('Выполнилась Q5'));
var t1 := Context.Default.BeginInvoke(
( WaitFor(Q1)+Q2 ) *
( WaitFor(Q1)+Q3 )
);
var t2 := Context.Default.BeginInvoke(
( WaitFor(Q1)+Q4 ) *
( WaitFor(Q1)+Q5 )
);
// Каждый вызов Q1 тут - активирует по одному WaitFor(Q1) в каждом .BeginInvoke
Context.Default.SyncInvoke(Q1+Q1);
t1.Wait;
t2.Wait;
Для начала немного о распространении ошибок в очередях:
Исключения в очереди Q отменяет выполнение всех очередей, ожидающих Q, но никак не влияет на параллельные к Q очереди:
## uses OpenCLABC;
function make_q(i: integer) :=
HPQ(()->lock output do Writeln(i));
try
Context.Default.SyncInvoke(
make_q(1) +
(make_q(2) * HPQ(()->raise new Exception('3')))
+ make_q(4)
);
except
on e: System.AggregateException do
Writeln(e.InnerExceptions.Single);
end;
Очередь 1 выполняется в любом случае, потому что очередь с ошибкой (3) находится позже;
Очередь 2 выполняется, потому что она стоит параллельно к очереди 3;
Очередь 3 выполняется и в процессе кидает исключение;
Очередь 4 пропускается, потому что ожидает успешного выполнения очередей 2 и 3.
Может звучать тривиально - но это базовое правило, распространяющееся на более сложные случаи. К примеру:
## uses OpenCLABC;
var M := WaitMarker.Create;
try
Context.Default.SyncInvoke(
(HPQ(()->raise new Exception('Special error')) + WaitFor(M))
*
(HPQ(()->Sleep(100)) + WaitFor(M))
*
M
);
except
on e: System.AggregateException do
Writeln(e.InnerExceptions.Single);
end;
Из за исключения первое WaitFor(M)
пропускается. Оно начинает ожидать
ещё до выхода из .BeginInvoke
, но затем отменяется, а значит не имеет
шанса поглотить сигнал от M
, оставляя его для второго WaitFor(M)
.
Так же надо сказать что .Multiusable
даёт множественный доступ
к любому результату выполнения очереди, будь то возвращаемое значение или ошибка:
## uses OpenCLABC;
var Qs := HPQ(()->raise new Exception('Special error')).Multiusable;
try
Context.Default.SyncInvoke( Qs()*Qs() );
except
on e: System.AggregateException do
e.InnerExceptions.PrintLines;
end;
Этот код выводит ошибку дважды, потому что она пришла из 2 параллельных веток, которые обе взяли её из общего источника.
Самый простой обработчик ошибок это .HandleWithoutRes
:
Он применим ко всем очередям, в том числе CommandQueueBase
, потому что игнорирует любое предыдущее
возвращаемое значение и создаёт CommandQueueNil
.
Есть два варианта вызова .HandleWithoutRes
:
on e: Exception do
:
## uses OpenCLABC;
var Q1 := HPQ(()->raise new Exception('Expected error'));
var Q2 := HPQ(()->raise new Exception('Unexpected error'));
try
Context.Default.SyncInvoke( (Q1*Q2)
// Ловим все исключения с указанным сообщением
// Для реального случая это плохо, но для демонстрации нормально
.HandleWithoutRes(e->(e.Message.Print='Expected error').Println)
);
except
on e: System.AggregateException do
e.InnerExceptions.PrintLines;
end;
on e: ... do
:
## uses OpenCLABC;
var Q1 := HPQ(()->raise new System.InvalidOperationException);
var Q2 := HPQ(()->raise new System.InvalidTimeZoneException);
try
Context.Default.SyncInvoke( (Q1*Q2)
// Ловим все исключения типа InvalidOperationException
// Опять же, вообще плохо съедать исключения без полной диагностики
.HandleWithoutRes&<System.InvalidOperationException>(e->true)
);
except
on e: System.AggregateException do
e.InnerExceptions.PrintLines;
end;
Далее, обработчик .HandleDefaultRes
:
Если в очереди было кинуто исключение - возвращаемое значение не существует (не nil
, а именно отсутствует).
Поэтому чтобы вернуть очередь с определённым типом возвращаемого значения,
необходимо заменить результат после успешной обработки исключения:
## uses OpenCLABC;
// Исключение, кидаемое при не_первых выполнениях этой очереди
var need_error := false;
var QErr := HPQ(()->
begin
if need_error then
raise new Exception else
need_error := true;
end);
loop 3 do Context.Default.SyncInvoke( (QErr+HFQ(()->5))
// Ловим все исключения типа Exception
// И заменяем результат на 0, если он отсутствует
.HandleDefaultRes&<Exception>(e->true, 0)
).Println;
И последний - обработчик .HandleReplaceRes
:
Не всегда значение, которым надо заменять возвращаемое значение очереди, можно посчитать до выполнения этой очереди.
В более сложном случае это значение может вычисляться во время выполнения, скорее всего используя пойманные исключения.
В таком случае нет смысла обрабатывать исключения по одному, потому что так можно получить несколько конфликтующих замен для возвращаемого значения:
## uses OpenCLABC;
var QErr := HPQ(()->raise new Exception);
Context.Default.SyncInvoke(
(QErr*QErr + HFQ(()->-1))
.HandleReplaceRes(lst->
begin
Result := lst.Count; // Возвращаемое значение
lst.Clear; // Считаем что обработали всё
end)
).Println;
.HandleReplaceRes
нельзя вызывать на Base
и Nil
очередях, потому что непонятно что возвращать если ошибка не возникла.
Но всегда можно прибавить константную очередь к обрабатываемой, таким образом указав результат по-умолчанию, на случай без ошибок:
## uses OpenCLABC;
var Q1 := HPQQ(()->begin end);
var Q2 := HPQQ(()->raise new Exception);
Context.Default.SyncInvoke((Q1*Q2 + CQ(0)).HandleReplaceRes(err_lst->
begin
err_lst.PrintLines;
err_lst.Clear;
Result := -1;
end)).Println;
Результат очереди из этого примера можно передавать напрямую в Halt
, если вы, к примеру, делаете инструмент для коммандной строки.
Обратите внимание, обработчики исключений всегда действую по принципу Quick
очередей.
То есть код выполняемый на CPU, который обрабатывает исключения, не должен содержать блокирующих вызовов модуля OpenCL
.
Но использовать затратные алгоритмы, или блокирующие вызовы как ввод с клавиатуры ('Ошибка, всё равно продолжить? [Y/n]'
) вполне нормально:
Обработчики вызываются только если было кинуто исключение. А исключения должны кидаться только в исключительных ситуациях, когда что-то пошло не так.
В паскале есть два типа блоков для обработки исключений, try-except
- собственно обработчик,
а так же try-finally
- позволяющий выполнять код не зависимо от того, было ли кинуто исключение.
В очередях OpenCLABC
аналог последнего выглядит так:
## uses OpenCLABC;
function QErr(i: integer) := HPQ(()->raise new Exception(i.ToString));
Context.Default.SyncInvoke(
( QErr(1)+QErr(2) >= QErr(3)+QErr(4) >= QErr(5)+QErr(6) )
.HandleWithoutRes(e->e.Message.Println<>nil)
);
В этом коде:
>=
, работающий как +
, но игнорирующий предыдущие исключения, поэтому очередь 3 выполняется.>=
, игнорирующий исключения всех предыдущих очередей, то есть 1 и 3, а значит очередь 5 тоже выполняется.Затем стоит обработчик, который съедает все исключения, но перед этим выводит их текст, показывая что выполнились очереди 1, 3 и 5.
Обычные .ThenMarkerSignal
и .ThenWaitFor
посылают/съедают сигнал маркера только если перед ними небыло ошибок.
Это не всегда хорошо, потому что в случае ошибок будет сложно судить о количестве сигналов.
Как общее правило - Wait
-очереди и маркеры лучше ставить после >=
.
Но HFQ(()->5) >= WaitFor(M)
вернёт результат последней очереди, то есть nil
.
Поэтому существуют методы .ThenFinallyMarkerSignal
и .ThenFinallyWaitFor
.
Эти методы так же как их варианты без Finally
- возвращают то что вернула исходная очередь.
Но в отличии от них - посылают/поглащают сигнал не зависимо от ошибок в очереди, из которой их создали.
Передавать команды по одной, когда их несколько - ужасно не эффективно!
Но нередко бывает так, что команда всего одна. Или для отладки надо одноразово выполнить несколько команд.
Для таких случаев можно создавать очередь неявно:
У каждого метода для создания комманды для GPU есть дублирующий метод в простой обёртке. Этот метод сам создаёт
новую очередь, добавляет в неё одну соответствующую команду и выполняет полученную очередь в Context.Default.SyncInvoke(...)
.
Обычный код с очередями:
## uses OpenCLABC;
var a := new CLArray<integer>(3);
Context.Default.SyncInvoke(a.NewQueue
.ThenWriteValue(1, 0)
.ThenWriteArray(|5,7|, 1, 2,0)
.ThenGetArray
).Println;
Он же, но с неявными очередями:
## uses OpenCLABC;
var a := new CLArray<integer>(3);
a.WriteValue(1, 0);
a.WriteArray(|5,7|, 1, 2,0);
a.GetArray.Println;
И в случае CLArray
можно ещё красивее:
## uses OpenCLABC;
var a := new CLArray<integer>(3);
a[0] := 1;
a.Section[1..2] := |5,7|;
a.Section[0..2].Println;
Все методы создающие одну команду (*CCQ.Add*
методы и все методы неявных очередей)
могут принимать очередь вместо значения в качестве практически любого параметра. Но в таком случае
возвращаемый тип очереди должен совпадать с типом параметра. К примеру:
## uses OpenCLABC;
var a := new CLArray<integer>(10);
// Очищаем весь буфер нулями, чтобы не было мусора
a.FillValue(0);
// Второй параметр AddWriteItem - индекс в массиве
// Он имеет тип integer, а значит можно передать и CommandQueue<integer>
// Таким образом, в параметр сохраняется алгоритм, а не готовое значение
// Поэтому 3 вызова ниже могут получится с 3 разными индексами
var q := a.NewQueue
.ThenWriteValue(5, HFQQ(()-> Random(a.Length) ))
;
Context.Default.SyncInvoke(q);
Context.Default.SyncInvoke(q);
Context.Default.SyncInvoke(q);
a.GetArray.Println;
Все вложенные очереди начинают выполняться сразу при вызове метода Context.BeginInvoke
, не ожидая других очередей.
Обычно вложенные очереди особо полезны когда надо записать что-то в память GPU прямо перед вызовом kernel'а:
k.Exec1(N, a.NewQueue.ThenFillValue(1))
Вместо:
a.NewQueue.ThenFillValue(1) +
k.Exec1(N, a)
В данной справке в нескольких местах можно встретить утверждения вроде
Вызовы
Context.BeginInvoke
стоит, по возможности, объединять.
Данный раздел подпробнее объясняет устройство модуля, что делает подобные утверждения более понятными.
Читать его не необходимо для написания работающего кода, но желательно для написания качественного кода.
Но стоит сказать заранее - это не полное объяснение внутренностей модуля. Объясняется только то, что скорее всего окажется нужным. Если хотите ещё более полное понимание - используйте Ctrl+клик в IDE по именам, чтобы смотреть исходный код.
Кроме самого выполнения очередей - им так же необходима инициализация и финализация.
Для начала, перед тем как любая из под-очередей в BeginInvoke
начнёт выполняться - необходимо инициалировать Wait
очереди.
Иначе у ожидаемого маркера всегда будет шанс выполнится до того как ожидающая его очередь начнёт ожидать.
Инициализация Wait
очередей заключается в обходе всего дерева под-очередей.
Для каждой Wait
-группы (очередей, ожидающих общий маркер в общем .BeginInvoke
),
создаётся счётчик выполнений ожидаемого маркера.
Тоже заключается в обходе дерева под-очередей.
Но в этот раз очереди по которым прошлись - уже начали выполнятся, не ожидая окончания обхода всего дерева.
Как только этот обход закончен - метод BeginInvoke
возвращает свой CLTask
коду, вызвавшему его.
Заключается в чистке после выполнения - к примеру удалении всех созданных cl_command_queue
.
Так же тут собирается информация о всех исключениях, пойманных во время выполнения.
Основное преимущество объединения вызовов BeginInvoke
состоит в различии следующих 2 случаев:
Context.Default.SyncInvoke(A+B);
Context.Default.SyncInvoke(A);
Context.Default.SyncInvoke(B);
В первом случае пока выполнится A
- B
уже, скорее всего, окажется полностью запущено.
А значит как только A
закончит выполнятся - ход выполнения перейдёт на B
.
А во втором случае - между окончанием выполнения A
и запуском B
- будет произведено множество проверок,
а так же выходов/входов в объёмные (что значит JIT их не инлайнит) подпрограммы, как конструктор CLTask
.
Так же, в первом случае многие ресурсы, как объекты OpenCL.cl_command_queue
,
выделенные при выполнении A
, будут ещё раз использованы для выполнения B
.
Да, всё это мелочи. Но нулевая задержка всегда лучше ненулевой.
Ну а когда всё же приходится вызывать 2 отдельных BeginInvoke
, к примеру на 2 разных
контекстах - можно использовать Wait
очереди, чтобы добится того же эффекта:
c2.BeginInvoke(WaitFor(A)+B);
c1.BeginInvoke(A);
Внутренние оптимизации OpenCLABC
делают этот код практически не отличимым по скорости, от BeginInvoke(A+B)
.
Единственное различие - время инициализации. Потому что A
не запустится, пока не закончится вызов c2.BeginInvoke
.