Данная справка относится к модулю OpenCLABC, входящему в состав стандартных модулей языка PascalABC.NET.

Модуль OpenCLABC это высокоуровневая оболочка модуля OpenCL.
Это значит, что с OpenCLABC можно писать гораздо меньше кода в больших и сложных программах, однако такой же уровень микроконтроля как с модулем OpenCL недоступен. Например, напрямую управлять cl_event'ами в OpenCLABC невозможно. Вместо этого надо использовать операции с очередями (например, сложение и умножение очередей)

Справка модуля OpenCL отсутствует. Вместо неё смотрите:

  • Общий гайд по использованию модулей OpenGL и OpenCL
  • Контейнер справок библиотеки OpenCL, на которой основан модуль OpenCL

Если в справке или модуле найдена ошибка, или чего-либо не хватает - пишите в issue.

  • CPU — Центральное Процессорное Устройство (процессор);

  • GPU — Графическое Процессорное Устройство (видеокарта);

  • Команда — запрос на выполнение чего-либо. К примеру:

    • Запрос на запуск программы на GPU;
    • Запрос на начало чтения данных из памяти 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 - Платформа, объединяющая несколько совместимых устройств;
  • Device - Устройство, поддерживающее OpenCL;
  • Context - Контекст выполнения;
  • Buffer - Область памяти GPU;
  • Kernel - Подпрограмма, выполняемая на GPU;
  • ProgramCode - Контейнер kernel'ов.

В списке устройств контекста могут быть только совместимые друг с другом устройства.
Коллекция совместимых устройств называется платформой и хранится в объектах типа 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:

  1. 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.
    
  2. 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.
    
  3. Выходить из подпрограммы, где объявили 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'ов.


Есть 2 способа создать объект типа 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<>, у которого есть:

  • Event'ы (но реализованы методами), позволяющие указать что должно выполнятся когда выполнение 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+Пробел.

Есть всего 10 базовых способов создать очередь:

  1. Из буфера / kernel'а

  2. Из готового результата

  3. Из обычной подпрограммы

  4. Из нескольких других очередей

  5. Из очереди + преобразования результата

  6. Из повторения одной очереди

  7. Несколько очередей с общей работой

  8. Особыми .Add* методами

  9. Из ожидания других очередей

  10. Не пытаясь

Самый просто способ создать очередь — выбрать объект типа 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 группы подпрограмм, создающих очереди для ожидания других очередей:

  1. Глобальные, WaitFor*:
    Ничего не делают сами, но блокируют выполнение пока указанные очереди не выполнятся.

  2. Особые методы .Add* - .AddWait*:
    Как и .AddQueue и .AddProc, .AddWait(...) это всего лишь аналог .AddQueue(WaitFor(...)), существующий только ради простоты кода.

  3. Методы очередей, .ThenWaitFor*:
    Так же не имеет незаменимых применений, но ещё полезнее.
    Q.ThenWaitFor(...) работает как Q + WaitFor(...), за исключением того, что возвращает результат Q.
    То есть, правильнее будет:

    var Qs := Q.Multiusable;
    Result := Qs() + WaitFor(...) + Qs();
    

    Но это уже даже в одну строчку не напишешь.

В каждой из групп Wait очередей - * можно заменить:

  1. Ничем: Ожидание одной очереди.
  2. На All: Ожидание всех указанных очередей.
  3. На 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

Кроме самого выполнения очередей - им так же необходима инициализация и финализация.


Инициализация

Для начала, перед тем как любая из под-очередей в 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.