Параллельные обрабатывающие строки 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/