Данная справка относится к модулю OpenCLABC
, входящему в состав стандартных модулей языка PascalABC.NET
.
Модуль OpenCLABC
это высокоуровневая оболочка модуля OpenCL
.
Это значит, что с OpenCLABC
можно писать гораздо меньше кода в больших и сложных программах,
однако такой же уровень микроконтроля как с модулем OpenCL
недоступен.
Например, напрямую управлять cl_event
'ами в OpenCLABC
невозможно.
Вместо этого надо использовать операции с очередями (например, сложение и умножение очередей)
Справка модуля OpenCL
отсутствует. Вместо неё смотрите:
OpenGL
и OpenCL
OpenCL
, на которой основан модуль OpenCL
Если в справке или модуле найдена ошибка, или чего-либо не хватает - пишите в issue.
CPU — Центральное Процессорное Устройство (процессор);
GPU — Графическое Процессорное Устройство (видеокарта);
Команда — запрос на выполнение чего-либо. К примеру:
Называть процедуры и функции командами ошибочно!
Подпрограмма — процедура или функция;
Метод — особая подпрограмма, вызываемая через экземпляр:
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;
begin
var pls := Platform.All;
pls.PrintLines;
end.
Обычно устройства получают статическим методом Device.GetAllFor
:
uses OpenCLABC;
begin
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;
end.
И в большинстве случаев - это всё что вам понадобится.
Но если где то нужен более тонкий контроль - можно создать несколько виртуальных
под-устройств, каждому из которых даётся часть ядер изначального устройства.
Для этого используются методы .Split*
:
uses OpenCLABC;
begin
var dvc := Context.Default.MainDevice;
Writeln('Поддерживаемые типы .Spilt-ов:');
// Пустой список, или DEVICE_PARTITION_BY_COUNTS_LIST_END, если ни 1 не поддерживается
dvc.Properties.PartitionProperties.PrintLines;
Writeln('='*30);
Writeln('Виртуальные устройства, по 1 ядру каждое:');
// Если упадёт потому что слишком много
// устройств - пожалуйста, напишите в issue
dvc.SplitEqually(1).PrintLines;
Writeln('='*30);
Writeln('Два устройства, 1 и 2 ядра соответственно:');
dvc.SplitByCounts(1,2).PrintLines;
end.
Для отправки команд в GPU необходим контекст (объект типа Context
):
Он содержит информацию о том, какие устройства будут использоваться для выполнения кода на GPU и хранения содержимого буферов.
Создать контекст можно конструктором (new Context(...)
).
Контекст можно и не создавать, используя везде свойство Context.Default
.
Неявные очереди всегда используют Context.Default
.
Изначально этому свойству присваивается контекст, использующий GPU, если оно имеется, или любое другое устройство, поддерживающее OpenCL, если GPU отсутствует.
Если устройств поддерживающих OpenCL
нет - Context.Default
будет nil
.
Однако такая ситуация на практике невозможна, потому что OpenCL поддерживается
практически всеми современными устройствами, занимающимися выводом изображения на экран.
Если Context.Default = nil
- переустановите графические драйверы.
Context.Default
можно перезаписывать.
Используйте эту возможность только если во всей программе нужен общий контекст, но не стандартный.
Если ваша программа достаточно сложная чтобы нуждаться в нескольких контекстах - лучше не использовать
Context.Default
. И присвоить ему nil
, чтобы не использовать случайно (к примеру, неявной очередью).
Программам, выпоняемые на GPU, не удобно использовать оперативную память.
Поэтому обычно надо выделять память на самом GPU.
Объекты типа Buffer
представляют такую область памяти.
Буфер создаётся конструктором (new Buffer(...)
).
Но если не передавать контекст в конструктор - память на GPU выделится только при вызове метода Buffer.Init
.
Если вызвать Buffer.Init
2 раза - память освободится и выделится заново.
Когда на GPU выделяется память - она НЕ очищается нулями, а значит содержит мусорные данные.
Если метод Buffer.Init
не был вызван до первой операции чтения/записи - он будет вызван автоматически.
В таком случае в качестве контекста в котором выделяется память берётся тот,
на котором вызвали .BeginInvoke
, который запустил команду чтения/записи буфера.
Память на GPU можно моментально освободить, вызвав метод Buffer.Dispose
.
Но если снова использовать буфер, у которого освободили память - память выделится заново.
Если сборщик мусора удаляет объект типа Buffer
- .Dispose
вызывается автоматически.
Но кроме выделения памяти GPU - OpenCL так же позволяет выделять память внутри другого буфера.
Для этого используется тип SubBuffer
:
uses OpenCLABC;
begin
var c := Context.Default;
// Не обязательно MainDevice, можно взять любое устройство из контекста
var align := c.MainDevice.Properties.MemBaseAddrAlign;
var b := new Buffer(align*2, c);
// size может быть любым, но origin
// должно быть align*N, где N - целое
var b1 := new SubBuffer(b, 0, Min(123,align));
var b2 := new SubBuffer(b, align, align);
Writeln(b1);
Writeln(b2);
end.
Объект типа 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;
Методы, запускающие Kernel
принимают специальные аргументы типа KernelArg
, которые передаются в OpenCL-C код.
Экземпляр KernelArg
может быть создан из нескольких типов значений, а точнее:
uses OpenCLABC;
begin
var k: Kernel;
var val1 := 3;
var val2 := 5;
k.Exec1(1,
// Передавать можно:
// Буфер
new Buffer(1),
// Очередь возвращающую буфер
HFQ(()->new Buffer(1)),
// В том числе BufferCommandQueue
Buffer.Create(1).NewQueue,
// Размерное значение
val1,
HFQ(()->val1),
// И указатель на размерное значение
// (в kernel попадёт само значение, не указатель)
@val2,
// Так нельзя, потому что val1 уже захвачена лямбдой из HFQ
// @val1,
// Расширенный набор параметров
KernelArg.FromPtr(new System.IntPtr(@val2), new System.UIntPtr(sizeof(integer)))
);
end.
Обратите внимание, KernelArg
из указателя на val2
будет немного эффективнее
чем KernelArg
из самого значения val2
. Но эту возможность стоит использовать
только как тонкую оптимизацию, потому что много чего может пойти не так.
Если передавать @val2
в качестве KernelArg
- надо знать все тонкости.
Если @val2
передали вкачестве KernelArg
:
val2
не может быть глобальной переменной или полем класса:
uses OpenCLABC;
type
t1 = class
val1 := 1; // Не подходит, потому что поле класса
static val2 := 2; // И статичных полей это тоже касается
end;
var
val3 := 3; // Глобальные переменные - тоже статические поля
k: Kernel; // k не важно где объявлена
procedure p1;
// Теоретически подходит, но вообще это плохой стиль кода
var val4 := 4;
begin
// А val5 однозначно подходит, потому что объявлена не только в той же
// подпрограмме, но и прямо перед использованием в k.Exec*
var val5 := 5;
k.Exec1(1,
// Это единственные 2 переменные, которые можно передавать адресом
@val4,
@val5
);
end;
begin end.
val2
не должно быть захвачего лямбдой.
Хотя указатель на val2
уже можно захватывать:
uses OpenCLABC;
begin
var k: Kernel;
var val1 := 3;
var val2 := 5;
// На val1 всё ещё накладываются все ограничени,
// когда val1_ptr использована в качестве KernelArg
// Но к самой val1_ptr эти ограничения не применяются
var val1_ptr := @val1;
k.Exec1(1,
val1,
HFQ(()->val1_ptr^), // захватили val1_ptr, а не val1
// val1 нигде не захвачена, поэтому теперь так можно
@val1,
val1_ptr // то же самое
);
end.
Выходить из подпрограммы, где объявили val2
нельзя, пока .Exec
не закончит выполнятся.
Это так же значит, что возвращать очередь, содержащую KernelArg
из @val2
обычно нельзя.
Но это можно обойти, если объявлять переменную в другой подпрограмме, а дальше передавать только её адрес:
uses OpenCLABC;
var k: Kernel; // Вообще лучше передавать параметром в p2
// Обратите внимание - val принимает var-параметром
// То есть, p2 принимает адрес val
// (можно так же принимать указатель, но это не так удобно)
// Иначе переменная будет копироваться при передаче
// То есть без var перед параметром - val тут
// будет новой переменной, объявленной в p2 а не в p1
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.
Компилятор не заставит вас следовать этим ограничениям. И программа может даже работать, игнорируя большинство сказанного тут.
Но потом вы добавите что то в совсем другой части программы, или запустите её на
другом компьютере и она вдруг начнёт выводить мусорные значения, или падать с ошибками
вроде AccessViolationException
, которые совершенно не объясняют в чём проблема.
Если очень интересно откуда берутся эти ограничения - начните с применения .Net декомпилятора к .exe файлам, получаемым при компиляции программ с данной страницы. Что останется непонятно - можете спрашивать в issue.
Обычные программы невозможно запустить на GPU. Для этого надо писать особые программы.
В контексте OpenCL - эти программы обычно пишутся на языке "OpenCL C" (основанном на языке "C").
Язык OpenCL-C это часть библиотеки OpenCL, поэтому его справку можно найти там же, где и справку OpenCL.
В OpenCLABC
код на языке "OpenCL C" хранится в объектах типа ProgramCode
.
Объекты этого типа используются только как контейнеры.
Один объект ProgramCode может содержать любое количествово подпрограмм-kernel'ов.
ProgramCode
:Конструктор ProgramCode
(new ProgramCode(...)
) принимает тексты исходников программы на языке OpenCL-C.
Именно тексты исходников, не имена файлов!
Так же, как исходники паскаля хранят в .pas файлах, исходники OpenCL-C кода хранят в .cl файлах. Но вообще, это не принципиально, потому что код даже не обязательно должен быть в файле.
Так как конструктор ProgramCode
принимает текст - исходники программы на OpenCL-C можно хранить даже в
строке в .pas программе. Тем не менее, храненить исходники OpenCL-C кода в .cl файлах обычно удобнее всего.
После создания объекта типа ProgramCode
из исходников можно вызвать
метод ProgramCode.SerializeTo
, чтобы сохранить код в бинарном и прекомпилированном виде.
Обычно это делается отдельной программой (не той же самой, которая будет использовать этот бинарный код).
После этого основная программа может создать объект ProgramCode
,
используя статический метод ProgramCode.DeserializeFrom
.
Пример можно найти в папке Прекомпиляция ProgramCode
или тут.
Передавать команды для GPU по одной не эффективно. Гораздо эффективнее передавать несколько команд сразу.
Для этого существуют очереди (типы, наследующие от CommandQueue<T>
и CommandQueueBase
).
Они хранят произвольное количество команд для GPU.
А при необходимости также и части кода, выполняемые на CPU.
У каждого типа-очереди есть свой тип возвращаемого значения.
К примеру, так объявляется переменная в которую можно будет сохранить очередь, возвращающую integer
:
var Q1: CommandQueue<integer>;
Очереди, созданные из буфера или kernel'а возващают свой Buffer
/Kernel
соответственно, из которого были созданы;
Очереди, созданные с HFQ
- значение, которое вернёт переданная функция;
Очереди, созданные с HPQ
- значение типа object
(и всегда nil
).
К примеру:
uses OpenCLABC;
/// Вывод типа и значения объекта
procedure OtpObject(o: object) :=
Writeln( $'{o?.GetType}[{_ObjectToString(o)}]' );
// "o?.GetType" это короткая форма "o=nil ? nil : o.GetType",
// то есть, берём или тип объекта, или nil если сам объект nil
// _ObjectToString это функция, которую использует Writeln для форматирования значений
begin
var b0 := new Buffer(1);
// Тип - буфер, потому что очередь создали из буфера
OtpObject( Context.Default.SyncInvoke( b0.NewQueue ) );
// Тип - Int32 (то есть integer), потому что это тип по умолчанию для выражения (5)
OtpObject( Context.Default.SyncInvoke( HFQ( ()->5 ) ) );
// Тип - string, по той же причине
OtpObject( Context.Default.SyncInvoke( HFQ( ()->'abc' ) ) );
// Тип отсутствует, потому что HPQ возвращает nil
OtpObject( Context.Default.SyncInvoke( HPQ( ()->Writeln('Выполнилась HPQ') ) ) );
end.
После выполнения очереди метод Context.SyncInvoke
возвращает то, что вернула очередь.
А если использовать метод Context.BeginInvoke
- возвращаемое значение можно получить с помощью метода CLTask.WaitRes
.
Бывает необходимо хранить несколько очередей, с разными возвращаемыми значениями, вместе. К примеру, в переменной типа List<>
.
Но в переменной типа CommandQueue<SomeT>
можно хранить только очередь с конкретным типом возвращаемого значения SomeT
.
Для того, чтобы хранить очереди с разными возвращаемыми значениями в одной переменной - используется CommandQueueBase
.
CommandQueueBase
это особый тип очереди, у которого не указывается возвращаемое значение.
От него наследует CommandQueue<>
, поэтому переменной типа CommandQueueBase
можно присвоить любую очередь.
Если попытаться выполнить такую очередь,
или применять операции преобразования очередей, как .ThenConvert
,
то тип возвращаемого значения будет восприниматься как Object
.
Самый простой способ выполнить очередь - вызвать метод Context.SyncInvoke
.
Он синхронно выполняет очередь и вызвращает её результат.
Но если надо выполнить очередь асинхронно - лучше использовать метод Context.BeginInvoke
.
Он запускает асинхронное выполнение очереди и как только очередь была полностью запущена - возвращает объект типа CLTask<>
, у которого есть:
CLTask
завершится.Метод Context.SyncInvoke
реализован как .BeginInvoke(...).WaitRes
.
Поэтому, везде где сказано "... происходит при вызове .BeginInvoke
", это же относится и к .SyncInvoke
.
У CLTask
, как и у очереди - в <>
, указывается тип возвращаемого значения. То есть:
var t: CLTask<integer>;
В такую переменную можно сохранить только результат Context.BeginInvoke
для очереди типа CommandQueue<integer>
.
И как и у CommandQueue
:
CLTaskBase
, у которого типа возвращаемого значения не указывается.CLTaskBase
можно присвоить CLTask
с любым возвращаемым значением.Context.BeginInvoke
для очереди типа CommandQueueBase
возвращает CLTaskBase
.Если при выполнении возникла ошибка, о ней выведет не полную информацию. Чтобы получить всю информацию - используется try
:
try
//ToDo ваш код, вызывающий ошибку
except
// Writeln выводит все внутренние исключения. "e.ToString" тоже.
on e: Exception do Writeln(e);
end;
Для этого кода есть стандартный снипет. Чтобы активировать его - напишите tryo
и нажмите Shift+Пробел.
Самый просто способ создать очередь — выбрать объект типа Buffer
или Kernel
и вызвать для него метод .NewQueue
.
Полученная очередь будет иметь особый тип: BufferCommandQueue
/KernelCommandQueue
для буфера/kernel'а соответственно.
К такой очереди можно добавлять команды, вызывая её методы, имена которых начинаются с .Add...
.
К примеру:
uses OpenCLABC;
begin
// Буфер достаточного размера чтоб содержать 3 значения типа integer
var b := new Buffer( 3*sizeof(integer) );
// Создаём очередь
var q := b.NewQueue;
// Добавлять команды в полученную очередь можно вызывая соответствующие методы
q.AddWriteValue(1, 0*sizeof(integer) );
// Методы, добавляющие команду в очередь - возвращают очередь, для которой их вызвали (не копию а ссылку на оригинал)
// Поэтому можно добавлять по несколько команд в 1 строчке:
q.AddWriteValue(5, 1*sizeof(integer) ).AddWriteValue(7, 2*sizeof(integer) );
// Все команды в q будут выполняться последовательно
Context.Default.SyncInvoke(q);
// Вообще чтение тоже надо делать через очереди, но для простого примера - и неявные очереди подходят
b.GetArray1&<integer>(3).Println;
end.
Также, очереди BufferCommandQueue
/KernelCommandQueue
можно создавать из очередей, возвращающих Buffer
/Kernel
соответственно. Для этого используется конструктор:
var q0: CommandQueue<Buffer>;
...
var q := new BufferCommandQueue(q0);
Переменной очереди можно присвоить значение, тип которого совпадает с возвращаемым значением очереди:
var q: CommandQueue<integer> := 5;
Этот код присваевает переменной q
константную очередь, которая ничего не выполняет и возвращает 5
.
Получить значение, из которого создали константную очередь, можно преобразовав её к ConstQueue<>
:
if q is ConstQueue<integer>(var cq) then
Writeln($'Очередь была создана из значения ({cq.Val})') else
Writeln($'Очередь не константная');
CommandQueueBase
также можно создать из возвращаемого значения, но для этого тип значения должен быть Object
:
var q: CommandQueueBase := 5 as object;
Чтобы получить значение, из которого создали константную очередь, когда не знаете его тип - используйте интерфейс IConstQueue
:
if q is IConstQueue(var cq) then
Writeln($'Очередь была создана из значения ({cq.GetConstVal})') else
Writeln($'Очередь не константная');
Иногда между командами для GPU надо вставить выполнение обычного кода на CPU. А разрывать для этого очередь на две части - плохо, потому что одна целая очередь всегда выполнится быстрее двух её частей.
Для таких случае существуют глобальные подпрограммы HFQ
и HPQ
:
HFQ — Host Function Queue
HPQ — Host Procedure Queue
(Хост в контексте OpenCL - это CPU, потому что с него посылаются команды для GPU)
Они возвращают очередь, выполняющую код (функцию/процедуру соотвественно) на CPU.
Пример применения приведён
на странице выше
.
Если во время выполнения очереди возникает какая-либо ошибка - весь пользовательский код,
выполняемый на CPU в этой очереди получает ThreadAbortException
:
uses OpenCLABC;
begin
var t := Context.Default.BeginInvoke(
HPQ(()->
begin
try
Sleep(1000);
except
on e: Exception do lock output do
begin
Writeln('Ошибка во время выполнения первого HPQ:');
Writeln(e);
Writeln;
end;
end;
// Это никогда не выполнится, потому что
// ThreadAbortException кидает себя ещё раз в конце try
Writeln(1);
end)
*
HPQ(()->
begin
Sleep(500);
raise new Exception('abc');
end)
);
try
t.Wait;
except
on e: Exception do lock output do
begin
Writeln('Ошибка во время выполнения очереди:');
Writeln(e);
end;
end;
end.
Исключение ThreadAbortException
во многом опасно.
Подробнее можно прочитать в справке от microsoft.
А в кратце, если в вашем коде была кинута ThreadAbortException
- становится очень сложно сказать что-либо о его состоянии.
Даже потокобезопастный код, как вызов Buffer.Dispose
, может привести к утечкам памяти, если посреди него кинут ThreadAbortException
.
Считайте получение ThreadAbortException
критической ошибкой, после которой очень желателен перезапуск всего .exe файла.
Если сложить две очереди 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;
begin
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 );
end.
Операторы += и *= также применимы к очередям.
И как и для чисел - A += B
работает как A := A+B
(и аналогично с *=).
А значит, возвращаемые типы очередей A и B должны быть одинаковыми, чтобы к ним можно было применить +=/*=.
Если надо сложить/умножить много очередей - лучше применять CombineSyncQueue
/CombineAsyncQueue
соответственно.
Эти подпрограммы работают немного быстрее чем сложение и умножение, если объединять больше двух очередей.
Кроме того, они могут принимать ещё один параметр перед очередями: Этот параметр позволяет указать функцию преобразования, которая использует результаты всех входных очередей:
uses OpenCLABC;
begin
var q1 := HFQ( ()->1 );
var q2 := HFQ( ()->2 );
// Выводит 2, то есть только результат последней очереди
// Так сделано из за вопросов производительности
Context.Default.SyncInvoke( q1+q2 ).Println;
// Однако бывает так, что нужны результаты всех сложенных/умноженных очередей
// В таком случае надо использовать CombineSyncQueue и CombineAsyncQueue
// А точнее их перегрузку, первый параметр которой - функция преобразования
Context.Default.SyncInvoke(
CombineSyncQueue(
results->results.JoinToString, // Функция преобразования
q1, q2
)
).Println;
// Теперь выводит строку "1 2". Это то же самое, что вернёт "Arr(1,2).JoinToString"
end.
Если надо с минимальными затратами изменить представление компилятора об очереди - лучше всего использовать .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> );
// Нельзя, преобразование между 2 записями, как из integer в byte - это сложный алгоритм
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> );
// Нельзя, Q4 возвращает не t2 а t1, поэтому к t2 преобразовать не получится
Context.Default.SyncInvoke( Q4.Cast&<t2> );
// Можно, потому что t2 наследует от t1
Context.Default.SyncInvoke( Q5.Cast&<t1> );
end.
Ну а если эти ограничения не подходят - остаётся только .ThenConvert
.
Он позволяет указать любой алгоритм преобразования, но работает медленнее:
uses OpenCLABC;
begin
var q := HFQ(()->123);
Context.Default.SyncInvoke(
q.ThenConvert(i -> i*2 )
).Println;
end.
В данный момент всё ещё не работает... Но уже совсем скоро, правда-правда!
Одну и ту же очередь можно использовать несколько раз, в том числе одновременно:
uses OpenCLABC;
begin
var Q := HPQ(()->lock output do Writeln('Q выполнилась'));
var t1 := Context.Default.BeginInvoke(Q);
var t2 := Context.Default.BeginInvoke(Q*Q);
t1.Wait;
t2.Wait;
end.
Но эта программа выведет "Q выполнилась" три раза, потому что при каждом упоминании - Q запускается ещё раз.
Это не всегда хорошо. К примеру, может быть так что Q
содержит какой то затратный алгоритм. Или ввод значения с клавиатуры:
uses OpenCLABC;
begin
var Q := HFQ(()->
begin
lock input do
Result := ReadInteger;
end);
Context.Default.SyncInvoke(CombineAsyncQueue(
res->res,
Q,
Q.ThenConvert(i->i*i),
Q.ThenConvert(i->i*i*i)
)).Println;
end.
Эта программа запросит три разных значения, что не всегда то что надо.
Чтоб использовать результат одной очереди несколько раз - используется .Multiusable
:
uses OpenCLABC;
begin
var Q := HFQ(()->
begin
lock input do
Result := ReadInteger;
end);
var Qs := Q.Multiusable;
Context.Default.SyncInvoke(CombineAsyncQueue(
res->res,
Qs(),
Qs().ThenConvert(i->i*i),
Qs().ThenConvert(i->i*i*i)
)).Println;
end.
.Multiusable
создаёт новую функцию, вызывая которую можно получить любое количество очередей,
у которых будет общий результат.
Каждый вызов .Multiusable
создаёт именно новую функцию.
Это значит, что если использовать результаты двух вызовов .Multiusable
- исходная очередь выполнится два раза.
.Multiusable
не работает между вызовами Context.BeginInvoke
:
uses OpenCLABC;
begin
var Q := HFQ(()->
begin
lock input do
Result := ReadInteger;
end);
var Qs := Q.Multiusable;
Context.Default.SyncInvoke( Qs() ).Println;
Context.Default.SyncInvoke( Qs().ThenConvert(i->i*i) ).Println;
end.
Эта программа запросит ввод два раза.
Если контекст у двух очередей общий - лучше объединить вызовы Context.BeginInvoke
.
Так не только .Multiusable
будет работать, но и выполнение будет в целом быстрее.
А если контекст разный - надо сохранять результат в переменную и использовать Wait
очереди
(подробнее на странице ниже).
Между командами для GPU (хранимыми в очередях типов BufferCommandQueue
и KernelCommandQueue
)
бывает надо вставить выполнение другой очереди или кода для CPU.
Это можно сделать, используя несколько .NewQueue
:
var b: Buffer;
var q0: CommandQueueBase;
...
var q :=
b.NewQueue.AddWriteValue(...) +
q0 +
HPQ(...) +
b.NewQueue.AddWriteValue(...)
;
Однако можно сделать и красивее:
var b: Buffer;
var q0: CommandQueueBase;
...
var q := b.NewQueue
.AddWriteValue(...)
.AddQueue(q0)
.AddProc(...)
.AddWriteValue(...)
;
(вообще callback AddProc'а принимает исходный буфер/kernel параметром, что делает его более похожим на .ThenConvert
, чем HPQ
)
Эти методы не имеют незаменимых применений, но позволяют сделать код значительно читабельнее.
Кроме того, будьте осторожны, защита от дурака для такого случая - отсутствует:
var q := b.NewQueue;
q.AddQueue(q);
Все .Add*
методы добавляют команды в существующую очередь, а не создают новую.
Поэтому очередь, созданная предыдущим кодом при попытке выполнения начнёт циклически запускать саму себя.
Есть всего 3 группы подпрограмм, создающих очереди для ожидания других очередей:
Глобальные, WaitFor*
:
Ничего не делают сами, но блокируют выполнение пока указанные очереди не выполнятся.
Особые методы .Add*
- .AddWait*
:
Как и .AddQueue
и .AddProc
, .AddWait(...)
это всего лишь аналог
.AddQueue(WaitFor(...))
, существующий только ради простоты кода.
Методы очередей, .ThenWaitFor*
:
Так же не имеет незаменимых применений, но ещё полезнее.
Q.ThenWaitFor(...)
работает как Q + WaitFor(...)
, за исключением того, что возвращает результат Q
.
То есть, правильнее будет:
var Qs := Q.Multiusable;
Result := Qs() + WaitFor(...) + Qs();
Но это уже даже в одну строчку не напишешь.
В каждой из групп Wait очередей - *
можно заменить:
All
: Ожидание всех указанных очередей.Any
: Ожидание любой из указанных очередей (какая раньше выполнится).То есть, к примеру, WaitForAny
создаёт очередь, которая сама ничего не выполняет, но ожидает выполнения любой из указанных очередей.
Wait
очереди делают реализацию некоторых сложных деревьев выполнения очередей возможными.
Примеры можно найти в папке PABCWork.NET\Samples\OpenCLABC\Wait очереди
.
Но они наиболее полезны благодаря одному особенному свойству:
Wait
очереди работают даже между вызовами Context.BeginInvoke
, в отличии от всего остального в OpenCLABC
.
Это не всегда безопастно:
Context.Default.BeginInvoke(Q1);
Context.Default.BeginInvoke(WaitFor(Q1) + Q2);
Проблема этого кода в том, что Q1
может закончить выполняться ещё до того как WaitFor(Q1)
начнёт ожидать.
Чтоб такое не происходило - надо всегда запускать ожидающую очередь раньше ожидаемой:
Context.Default.BeginInvoke(WaitFor(Q1) + Q2);
Context.Default.BeginInvoke(Q1);
Но, как всегда, лучше, по возможности, объединять вызовы Context.BeginInvoke
:
Context.Default.BeginInvoke(
( Q1 ) *
( WaitFor(Q1) + Q2 )
);
Все ожидающие очереди начинают ожидать в самом начале вызова Context.BeginInvoke
, перед тем как очередь начнёт выполнятся.
Поэтому если ожидающая и ожидаемая очереди находятся в общем Context.BeginInvoke
- о их порядке можно не волноваться.
Когда начинает выполняться Context.BeginInvoke
- для каждой ожидающей очереди в соответствующей
ожидаемой очереди создаётся новый счётчик для того, сколько раз ожидаемая очередь была выполнена.
Поэтому можно делать так:
uses OpenCLABC;
begin
var Q1 := HPQ(()->
begin
Sleep(1000);
lock output do Writeln('Выполнилась Q1');
end);
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);
t1.Wait; // Чтобы вывести ошибки, если возникнут при выполнении
end.
Ну и, конечно, лучше совместить вызовы Context.BeginInvoke
, раз контекст общий:
uses OpenCLABC;
begin
var Q1 := HPQ(()->
begin
Sleep(1000);
lock output do Writeln('Выполнилась Q1');
end);
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)
);
end.
Каждое окончание выполнения Q1
добавляет 1 в счётчик внутри Q1
.
Каждое окончание ожидания WaitFor(Q1)
отнимание 1 от того же счётчика.
Будьте осторожны, лишняя Wait
очередь вызовет зависание:
uses OpenCLABC;
begin
var Q1 := HFQ(()->0);
var t1 := Context.Default.BeginInvoke(
WaitFor(Q1) +
WaitFor(Q1) // второй запуск Q1 никогда не произойдёт, поэтому это зависнет
);
Context.Default.SyncInvoke(Q1);
t1.Wait;
end.
Cчётчик создаётся для каждой пары [ожидаемой очереди] и [вызова Context.BeginInvoke
с ожидающей очередью].
То есть это тоже сработает:
uses OpenCLABC;
begin
var Q1 := HPQ(()->
begin
Sleep(1000);
lock output do Writeln('Выполнилась Q1');
end);
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 тут - активирует по 1 WaitFor(Q1) в каждом CLTask
Context.Default.SyncInvoke(Q1+Q1);
t1.Wait;
t2.Wait;
end.
Но каждый Context.BeginInvoke
для ожидаемой очереди - добавляет в уже существующий счётчик:
uses OpenCLABC;
begin
var Q1 := HPQ(()->
begin
Sleep(1000);
lock output do Writeln('Выполнилась Q1');
end);
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 t1 := Context.Default.BeginInvoke(
( WaitFor(Q1)+Q3 ) *
( WaitFor(Q1)+Q4 )
);
var t2 := Context.Default.BeginInvoke(WaitFor(Q1)+Q2+Q1);
Context.Default.SyncInvoke(Q1);
t1.Wait;
t2.Wait;
end.
Передавать команды по одной, когда их несколько - ужасно не эффективно!
Но нередко бывает так, что команда всего одна. Или для отладки надо одноразово выполнить одну команду.
Для таких случаев можно создавать очередь неявно:
У каждого .Add*
метода есть дублирующий метод в оригинальном объекте. Такие методы сами создают
новую очередь, добавляют в неё одну соответствующую команду и выполняют полученную очередь в Context.Default.SyncInvoke(...)
.
Обычный код с очередями:
uses OpenCLABC;
begin
var b := new Buffer( 3*sizeof(integer) );
var Q_BuffWrite := b.NewQueue
.AddWriteValue(1, 0*sizeof(integer) )
.AddWriteValue(5, 1*sizeof(integer) )
.AddWriteValue(7, 2*sizeof(integer) )
;
var Q_BuffRead :=
b.NewQueue.AddGetArray1&<integer>
.ThenConvert(A->A.Println);
;
Context.Default.SyncInvoke(
Q_BuffWrite +
Q_BuffRead
);
end.
Он же, но с неявными очередями:
uses OpenCLABC;
begin
var b := new Buffer( 3*sizeof(integer) );
// Аналог Q_BuffWrite
b.WriteValue(1, 0*sizeof(integer) );
b.WriteValue(5, 1*sizeof(integer) );
b.WriteValue(7, 2*sizeof(integer) );
// Аналог Q_BuffRead
b.GetArray1&<integer>.Println;
end.
Все методы создающие одну команду (.Add*
методы и все методы неявных очередей)
могут принимать очередь вместо значения в качестве любого параметра. Но в таком случае
возвращаемый тип очереди должен совпадать с типом параметра. К примеру:
uses OpenCLABC;
begin
var b := new Buffer(10*sizeof(integer));
// Очищаем весь буфер ноликами, чтобы не было мусора
b.FillValue(0);
var q := b.NewQueue
// Второй параметр AddWriteValue - отступ от начала буфера
// Он имеет тип integer, а значит можно передать и CommandQueue<integer>
// Таким образом, в параметр сохраняется алгоритм, а не готовое значение
// Поэтому 3 вызова ниже могут получится с 3 разными отступами
.AddWriteValue(5, HFQ(()-> Random(0,9)*sizeof(integer) ))
as CommandQueue<Buffer>;
Context.Default.SyncInvoke(q);
Context.Default.SyncInvoke(q);
Context.Default.SyncInvoke(q);
b.GetArray1&<integer>.Println;
end.
Все вложенные очереди начинают выполняться сразу при вызове метода Context.BeginInvoke
, не ожидая других очередей.
Обычно вложенные очереди используются при вызове kernel'а, когда надо записать что-то в буфер прямо перед вызовом kernel'а.
В данной справке в нескольких местах можно встретить утверждения вроде
Вызовы
Context.BeginInvoke
стоит, по возможности, объединять.
Данный раздел подпробнее объясняет устройство модуля, что делает подобные утверждения более понятными.
Читать его не необходимо для написания работающего кода, но желательно для написания качественного кода.
Но стоит сказать заранее - это не полное объяснение внутренностей модуля. Объясняется только то, что скорее всего окажется нужным. Если хотите ещё более полное понимание - используйте Ctrl+клик в IDE по именам, чтоб смотреть исходный код.
Кроме самого выполнения очередей - им так же необходима инициализация и финализация.
Для начала, перед тем как любая из под-очередей в BeginInvoke
начнёт выполняться - необходимо инициалировать Wait
очереди.
Иначе у ожидаемой очереди всегда будет шанс выполнится до того как ожидающая начнёт ожидать.
Инициализация Wait
очередей заключается в обходе всего дерева под-очередей. Для каждой Wait
очереди, ожидающей уникальную очередь,
создаётся пара ключ-значения (CLTask
и счётчик для него) и добавляется обработчик CLTask.WhenDone
с удалением этой пары.
Тоже заключается в обходе дерева под-очередей.
Но в этот раз очереди по которым прошлись - уже начали выполнятся, не ожидая окончания обхода всего дерева.
Как только этот обход закончен - метод BeginInvoke
возвращает свой CLTask
коду, вызвавшему его.
Единственное исключение - если очередь, каким то образом, уже завершила выполняться, к моменту как обход дерева закончился.
Такое возможно, к примеру, если все под-очереди - константные. В таком случае финализация выполняется до выхода из BeginInvoke
.
В основном заключается в вызове обработчиков события, как CLTask.WhenDone
.
Обработчики созданные Wait
очередями ничем не особенны, поэтому они тоже выполняются тут.
Основное преимущество объединения вызовов BeginInvoke
состоит в различии следующих 2 случаев:
Context.Default.SyncInvoke(A+B);
Context.Default.SyncInvoke(A);
Context.Default.SyncInvoke(B);
В первом случае пока выполнится A
- B
уже, скорее всего, окажется полностью запущено.
А значит как только A
закончит выполнятся - ход выполнения перейдёт на B
.
А во втором случае - между окончанием выполнения A
и запуском B
- будет произведено множество проверок,
а так же выходов/входов в объёмные (что значит JIT их не инлайнит) подпрограммы, как конструктор CLTask
.
Да, всё это мелочи. Но нулевая задержка всегда лучше ненулевой.
Ну а когда всё же приходится вызывать 2 отдельных BeginInvoke
, к примеру на 2 разных
контекстах - можно использовать Wait
очереди, чтоб добится того же эффекта:
c2.BeginInvoke(WaitFor(A)+B);
c1.BeginInvoke(A);
Внутренние оптимизации OpenCLABC
делают этот код практически не отличимым по скорости, от BeginInvoke(A+B)
.
Единственное различие - время инициализации. Потому что A
не запустится, пока не закончится обход дерева c2.BeginInvoke
.