Параллельные обрабатывающие строки Delphi полностью доступное использование ЦП

Цель состоит в том, чтобы обеспечить полное использование доступных ядер при конвертации поплавков в строки в одном приложении Delphi. Я думаю, что эта проблема относится к общей обработке строки. Тем не менее, в моем примере я специально использую метод FloatToStr.

То, что я делаю (я сохранил это очень просто, поэтому вокруг реализации не так много двусмысленности):

  • Использование Delphi XE6
  • Создайте объекты потоков, которые наследуются от TThread, и запустите их.
  • В процедуре выполнения потока он преобразует большое количество дублируется в строки методом FloatToStr.
  • Чтобы упростить, эти двойники - это одна и та же константа, поэтому нет общий или глобальный ресурс памяти, необходимый для потоков.

Несмотря на то, что используются несколько ядер, использование CPU% всегда будет превышать количество одного ядра. Я понимаю, что это проблема. Поэтому у меня есть некоторые конкретные вопросы.

Простым способом выполнения одной операции может быть множество экземпляров приложения и, следовательно, более полное использование доступного процессора. Возможно ли сделать это эффективно в пределах одного исполняемого файла? То есть назначить потокам разные идентификаторы процессов на уровне ОС или какое-то эквивалентное подразделение, распознанное ОС? Или это просто невозможно сделать из коробки Delphi?

По области: Я знаю, что есть разные диспетчеры памяти, и другие группы пытались изменить некоторые из более низких уровней использования asm lock http://synopse.info/forum/viewtopic.php?id=57 Но я задаю этот вопрос в том смысле, что не делаю ничего на таком низком уровне.

Спасибо


Привет. Мой код преднамеренно очень прост:

TTaskThread = class(TThread)
public
  procedure Execute; override;
end;

procedure TTaskThread.Execute;
var
  i: integer;
begin
  Self.FreeOnTerminate := True;
  for i := 0 to 1000000000 do
    FloatToStr(i*1.31234);
end;

procedure TfrmMain.Button1Click(Sender: TObject);
var
  t1, t2, t3: TTaskThread;
begin
  t1 := TTaskThread.Create(True);
  t2 := TTaskThread.Create(True);
  t3 := TTaskThread.Create(True);
  t1.Start;
  t2.Start;
  t3.Start;
end;

Это "тестовый код", где CPU (через монитор производительности) выходит на 25% (у меня 4 ядра). Если линия FloatToStr заменяется на нестрочную операцию, например. Power (i, 2), то монитор производительности показывает ожидаемое 75% -ное использование. (Да, есть более эффективные способы измерения этого, но я думаю, что этого достаточно для объема этого вопроса)

Я достаточно подробно изучил этот вопрос. Цель вопроса заключалась в том, чтобы сформулировать суть проблемы в очень простой форме.

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

Спасибо.

Ответ 1

Я второй, что все остальные сказали в комментариях. Это один из грязных маленьких секретов Delphi, что менеджер памяти FastMM не масштабируется.

Так как менеджеры памяти могут быть заменены, вы можете просто заменить FastMM на масштабируемый менеджер памяти. Это быстро меняющееся поле. Новые масштабируемые менеджеры памяти появляются каждые несколько месяцев. Проблема в том, что трудно написать правильный масштабируемый менеджер памяти. К чему вы готовы доверять? Одна вещь, которую можно сказать в пользу FastMM, заключается в том, что она надежна.

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

Как только вы решите избежать выделения кучи, следующим решением будет использовать вместо FloatToStr. По моему опыту библиотека времени исполнения Delphi не предлагает большой поддержки. Например, недавно я обнаружил, что нет хорошего способа конвертировать целое число в текст, используя буфер, предоставленный вызывающим абонентом. Таким образом, вам может понадобиться перевернуть свои собственные функции преобразования. В качестве простого первого шага, чтобы доказать точку, попробуйте позвонить sprintf из msvcrt.dll. Это обеспечит доказательство концепции.

Ответ 2

Если вы не можете изменить диспетчер памяти (MM), единственное, что нужно сделать, это не использовать его там, где MM может быть узким местом.

Что касается преобразования float в строку (Disclamer: я проверил код ниже с Delphi XE) вместо

procedure Test1;
var
  i: integer;
  S: string;

begin
  for i := 0 to 10 do begin
    S:= FloatToStr(i*1.31234);
    Writeln(S);
  end;
end;

вы можете использовать

procedure Test2;
var
  i: integer;
  S: string;
  Value: Extended;

begin
  SetLength(S, 64);
  for i := 0 to 10 do begin
    Value:= i*1.31234;
    FillChar(PChar(S)^, 64, 0);
    FloatToText(PChar(S), Value, fvExtended, ffGeneral, 15, 0);
    Writeln(S);
  end;
end;

которые производят тот же результат, но не выделяют память внутри цикла.

Ответ 3

И обратите внимание

function FloatToStr(Value: Extended): string; overload;
function FloatToStr(Value: Extended; const FormatSettings: TFormatSettings): string; overload;

Первая форма FloatToStr не является потокобезопасной, поскольку она использует информацию о локализации, содержащуюся в глобальных переменных. Вторая форма FloatToStr, которая является потокобезопасной, относится к информации о локализации, содержащейся в параметре FormatSettings. Прежде чем вызывать потокобезопасную форму FloatToStr, вы должны заполнить FormatSettings информацией о локализации. Чтобы заполнить FormatSettings набором значений языкового стандарта по умолчанию, вызовите GetLocaleFormatSettings.

Ответ 4

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

(Следует также отметить использование 64-разрядной версии XE6)

Результаты/наблюдения эксперимента:

  • использование процессора% было пропорционально количеству запущенных потоков (т.е. каждый поток = 1 ядро ​​максимизируется через монитор производительности).
  • как и ожидалось, при запуске большего количества потоков производительность несколько ухудшилась для каждого отдельного (т.е. время, измеренное для выполнения задачи - см. код).

времена - это только приблизительные средние значения

  • 8 ядер 3,3 ГГц - 1 поток занял 4200 мс. 6 потоков заняли 5200 м. Каждый.
  • 8 ядер 2,5 ГГц - 1 поток занял 4800 мс. 2 = > 4800 мс, 4 = > 5000 мс, 6 = > 6300 мс.

Я не подсчитал общее время для полного запуска нескольких потоков. Просто наблюдалось использование процессора% и измеренное время отдельных потоков.

Лично я нахожу немного веселым, что это действительно работает:) Или, возможно, я сделал что-то ужасно неправильно?

Конечно, есть библиотеки, которые разрешают эти вещи?

Код:

unit Main;

interface

uses
  Winapi.Windows, Winapi.Messages, System.SysUtils, System.Variants, System.Classes, Vcl.Graphics,
  Vcl.Controls, Vcl.Forms, Vcl.Dialogs, Vcl.StdCtrls,
  Generics.Collections,
  DateUtils;

type
  TfrmParallel = class(TForm)
    Button1: TButton;
    Memo1: TMemo;
    procedure Button1Click(Sender: TObject);
  private
    { Private declarations }
  public
    { Public declarations }
  end;

  TTaskThread = class(TThread)
  private
    Fl: TList<double>;
  public
    procedure Add(l: TList<double>);
    procedure Execute; override;
  end;

var
  frmParallel: TfrmParallel;

implementation

{$R *.dfm}

{  TTaskThread  }

procedure TTaskThread.Add(l: TList<double>);
begin
  Fl := l;
end;

procedure TTaskThread.Execute;
var
  i, j: integer;
  s, xs: shortstring;

  FR: TFloatRec;
  V: double;
  Precision, D: integer;

  ZeroCount: integer;

  Start, Finish: TDateTime;

  procedure AppendByteToString(var Result: shortstring; const B: Byte);
  const
    A1 = '1';
    A2 = '2';
    A3 = '3';
    A4 = '4';
    A5 = '5';
    A6 = '6';
    A7 = '7';
    A8 = '8';
    A9 = '9';
    A0 = '0';
  begin
    if B = 49 then
      Result := Result + A1
    else if B = 50 then
      Result := Result + A2
    else if B = 51 then
      Result := Result + A3
    else if B = 52 then
      Result := Result + A4
    else if B = 53 then
      Result := Result + A5
    else if B = 54 then
      Result := Result + A6
    else if B = 55 then
      Result := Result + A7
    else if B = 56 then
      Result := Result + A8
    else if B = 57 then
      Result := Result + A9
    else
      Result := Result + A0;
  end;

  procedure AppendDP(var Result: shortstring);
  begin
    Result := Result + '.';
  end;

begin
  Precision := 9;
  D := 1000;
  Self.FreeOnTerminate := True;
  //
  Start := Now;
  for i := 0 to Fl.Count - 1 do
  begin
    V := Fl[i];   

//    //orignal way - just for testing
//    xs := shortstring(FloatToStrF(V, TFloatFormat.ffGeneral, Precision, D));

    //1. get float rec     
    FloatToDecimal(FR, V, TFloatValue.fvExtended, Precision, D);
    //2. check sign
    if FR.Negative then
      s := '-'
    else
      s := '';
    //2. handle negative exponent
    if FR.Exponent < 1 then
    begin
      AppendByteToString(s, 0);
      AppendDP(s);
      for j := 1 to Abs(FR.Exponent) do
        AppendByteToString(s, 0);
    end;      
    //3. count consecutive zeroes
    ZeroCount := 0;
    for j := Precision - 1 downto 0 do
    begin
      if (FR.Digits[j] > 48) and (FR.Digits[j] < 58) then
        Break;
      Inc(ZeroCount);
    end;
    //4. build string
    for j := 0 to Length(FR.Digits) - 1 do
    begin
      if j = Precision then
        Break;
      //cut off where there are only zeroes left up to precision
      if (j + ZeroCount) = Precision then
        Break;
      //insert decimal point - for positive exponent
      if (FR.Exponent > 0) and (j = FR.Exponent) then
        AppendDP(s);
      //append next digit
      AppendByteToString(s, FR.Digits[j]);
    end;      

//    //use just to test agreement with FloatToStrF
//    if s <> xs then
//      frmParallel.Memo1.Lines.Add(string(s + '|' + xs));

  end;
  Fl.Free;

  Finish := Now;
  //
  frmParallel.Memo1.Lines.Add(IntToStr(MillisecondsBetween(Start, Finish))); 
  //!YES LINE IS NOT THREAD SAFE!
end;

procedure TfrmParallel.Button1Click(Sender: TObject);
var
  i: integer;
  t: TTaskThread;
  l: TList<double>;
begin
  //pre generating the doubles is not required, is just a more useful test for me
  l := TList<double>.Create;
  for i := 0 to 10000000 do
    l.Add(Now/(-i-1)); //some double generation
  //
  t := TTaskThread.Create(True);
  t.Add(l);
  t.Start;
end;

end.

Ответ 5

FastMM4, по умолчанию, при конфликте потоков, когда один поток не может получить доступ к данным, заблокирован другим потоком, вызывает функцию Windows API Sleep (0), а затем, если блокировка по-прежнему недоступна, входит в цикл путем вызова Сон (1) после каждой проверки блокировки.

Каждый вызов Sleep (0) испытывает дорогостоящую стоимость контекстного переключателя, который может быть 10000+ циклов; он также страдает от стоимости кольца 3 до 0 переходов, которые могут быть 1000+ циклов. Что касается Sleep (1) - помимо затрат, связанных с Sleep (0), это также задерживает выполнение не менее 1 миллисекунды, управление привязкой к другим потокам и, если нет потоков, ожидающих выполнения физическим ядром ЦП, помещает ядро ​​в сон, эффективно уменьшая потребление ЦП и потребление энергии.

Вот почему в вашем случае использование ЦП никогда не достигало 100% - из-за Sleep (1), выпущенного FastMM4.

Этот способ получения блокировок не является оптимальным.

Лучшим способом было бы прятать блокировку порядка 5000 pause, а если блокировка все еще была занята, вызывается вызов API SwitchToThread(). Если pause недоступен (на очень старых процессорах без поддержки SSE2) или вызове API SwitchToThread() не было доступно (в очень старых версиях Windows, до Windows 2000), лучшим решением было бы использовать EnterCriticalSection/LeaveCriticalSection, которые не имеют задержки, связанные с Sleep (1), и которые также очень эффективно уступают управление ядру процессора другим потокам. Я изменил FastMM4, чтобы использовать новый подход к ожиданию блокировки: CriticalSections вместо Sleep(). С этими параметрами Sleep() никогда не будет использоваться, но вместо этого будет использоваться EnterCriticalSection/LeaveCriticalSection. Тестирование показало, что подход использования CriticalSections вместо Sleep (который использовался по умолчанию ранее в FastMM4) обеспечивает значительный выигрыш в ситуациях, когда количество потоков, работающих с менеджером памяти, совпадает или больше, чем количество физических ядер. Коэффициент усиления еще более заметен на компьютерах с несколькими физическими процессорами и неравномерным доступом к памяти (NUMA). Я использовал параметры компиляции, чтобы отменить оригинальный метод FastMM4 для использования Sleep (InitialSleepTime), а затем Sleep (AdditionalSleepTime) (или Sleep (0) и Sleep (1)) и заменить их EnterCriticalSection/LeaveCriticalSection, чтобы сэкономить ценные циклы CPU (0) и улучшить скорость (уменьшить латентность), которая была затронута каждый раз не менее чем на 1 миллисекунду спящим (1), потому что критические секции намного более удобны для процессора и имеют определенно более низкую задержку, чем Sleep (1).

Когда эти параметры включены, FastMM4-AVX проверяет:

  • поддерживает ли процессор SSE2 и, таким образом, инструкцию "пауза" и
  • имеет ли операционная система вызов API SwitchToThread(), и

    и в этом случае использует "паузу" спин-петлю для 5000 итераций, а затем вместо SwitchToThread() вместо критических секций; Если у процессора нет "паузы", или Windows не имеет функции API SwitchToThread(), она будет использовать EnterCriticalSection/LeaveCriticalSection. Я предоставил вилку под названием FastMM4-AVX на https://github.com/maximmasiutin/FastMM4

Вот сравнение оригинальной версии FastMM4 версии 4.992 с параметрами по умолчанию, скомпилированными для Win64 Delphi 10.2 Tokyo (Release with Optimization) и текущей ветвью FastMM4-AVX. В некоторых сценариях ветка FastMM4-AVX более чем в два раза быстрее, чем оригинальная FastMM4. Тесты выполнялись на двух разных компьютерах: один под Xeon E6-2543v2 с 2 гнездами процессора, каждый из которых имеет 6 физических ядер (12 логических потоков) - с 5 физическими ядрами для каждого сокета, включенными для тестового приложения. Еще один тест проводился под процессором i7-7700K.

Использовал тестовые примеры "Многопоточные распределения, использования и бесплатного" и "NexusDB" из набора тестов FastCode Challenge Memory Manager, модифицированного для работы под 64-разрядными версиями.

                     Xeon E6-2543v2 2*CPU     i7-7700K CPU
                    (allocated 20 logical  (allocated 8 logical
                     threads, 10 physical   threads, 4 physical
                     cores, NUMA)           cores)

                    Orig.  AVX-br.  Ratio   Orig.  AVX-br. Ratio
                    ------  -----  ------   -----  -----  ------
02-threads realloc   96552  59951  62.09%   65213  49471  75.86%
04-threads realloc   97998  39494  40.30%   64402  47714  74.09%
08-threads realloc   98325  33743  34.32%   64796  58754  90.68%
16-threads realloc  116708  45855  39.29%   71457  60173  84.21%
16-threads realloc  116273  45161  38.84%   70722  60293  85.25%
31-threads realloc  122528  53616  43.76%   70939  62962  88.76%
64-threads realloc  137661  54330  39.47%   73696  64824  87.96%
NexusDB 02 threads  122846  90380  73.72%   79479  66153  83.23%
NexusDB 04 threads  122131  53103  43.77%   69183  43001  62.16%
NexusDB 08 threads  124419  40914  32.88%   64977  33609  51.72%
NexusDB 12 threads  181239  55818  30.80%   83983  44658  53.18%
NexusDB 16 threads  135211  62044  43.61%   59917  32463  54.18%
NexusDB 31 threads  134815  48132  33.46%   54686  31184  57.02%
NexusDB 64 threads  187094  57672  30.25%   63089  41955  66.50%

Ваш код, который вызывает FloatToStr, в порядке, поскольку он выделяет строку результата с использованием диспетчера памяти, затем перераспределяет ее и т.д. Еще лучше было бы явно освободить ее, например:

procedure TTaskThread.Execute;
var
  i: integer;
  s: string;
begin
  for i := 0 to 1000000000 do
  begin
    s := FloatToStr(i*1.31234);
    Finalize(s);
  end;
end;

Вы можете найти лучшие тесты менеджера памяти в тестовом наборе тестов FastCode на http://fastcode.sourceforge.net/