Почему локальное хранилище потоков так медленно?
Я работаю над пользовательским распределителем памяти стиля выпуска метки для языка программирования D, который работает путем выделения из локальных потоков. Кажется, что узкое место в локальном хранилище потока приводит к огромному (~50%) замедлению выделения памяти из этих областей по сравнению с идентичной в остальном однопоточной версией кода, даже после того, как мой код имел только один поиск TLS на выделение / открепление. Это основано на выделении / освобождении памяти большое количество раз в цикле, и я пытаюсь выяснить, является ли это артефактом моего метода сравнительного анализа. Насколько я понимаю, локальное хранилище потока должно в основном включать доступ к чему-либо через дополнительный уровень косвенности, аналогично доступу к переменной через указатель. Это неверно? Сколько накладных расходов обычно имеет локальное хранилище потока?
Примечание. Хотя я упоминаю D, меня также интересуют общие ответы, не относящиеся к D, поскольку реализация D локального хранилища потоков, вероятно, улучшится, если она будет медленнее, чем в лучших реализациях.
6 ответов
Скорость зависит от реализации TLS.
Да, вы правы, что TLS может быть так же быстро, как поиск указателя. Это может быть даже быстрее в системах с блоком управления памятью.
Для поиска указателя вам нужна помощь от планировщика. Планировщик должен - при переключении задач - обновить указатель на данные TLS.
Другой быстрый способ внедрения TLS - через модуль управления памятью. Здесь TLS обрабатывается как любые другие данные, за исключением того, что переменные TLS размещаются в специальном сегменте. При переключении задач планировщик отобразит правильный фрагмент памяти в адресное пространство задачи.
Если планировщик не поддерживает ни один из этих методов, компилятор / библиотека должны сделать следующее:
- получить текущий ThreadId
- Возьми семафор
- Поиск указателя на блок TLS по ThreadId (может использовать карту или около того)
- Освободить семафор
- Верните этот указатель.
Очевидно, что выполнение всего этого для каждого доступа к данным TLS занимает некоторое время и может потребовать до трех вызовов ОС: получение ThreadId, получение и освобождение семафора.
Кстати, семафор необходим для того, чтобы ни один поток не читал из списка указателей TLS, пока другой поток находится в процессе создания нового потока. (и как таковой выделите новый блок TLS и измените структуру данных).
К сожалению, на практике медленная реализация TLS не редкость.
Местные нити в D действительно быстрые. Вот мои тесты.
64-битная Ubuntu, core i5, dmd v2.052 Опции компилятора: dmd -O -release -inline -m64
// this loop takes 0m0.630s
void main(){
int a; // register allocated
for( int i=1000*1000*1000; i>0; i-- ){
a+=9;
}
}
// this loop takes 0m1.875s
int a; // thread local in D, not static
void main(){
for( int i=1000*1000*1000; i>0; i-- ){
a+=9;
}
}
Таким образом, мы теряем только 1,2 секунды одного из ядер ЦП на 1000*1000*1000 локальных обращений к потокам. Доступ к локальным потокам осуществляется через регистр%fs, поэтому задействована только пара команд процессора:
Разборка с помощью objdump -d:
- this is local variable in %ecx register (loop counter in %eax):
8: 31 c9 xor %ecx,%ecx
a: b8 00 ca 9a 3b mov $0x3b9aca00,%eax
f: 83 c1 09 add $0x9,%ecx
12: ff c8 dec %eax
14: 85 c0 test %eax,%eax
16: 75 f7 jne f <_Dmain+0xf>
- this is thread local, %fs register is used for indirection, %edx is loop counter:
6: ba 00 ca 9a 3b mov $0x3b9aca00,%edx
b: 64 48 8b 04 25 00 00 mov %fs:0x0,%rax
12: 00 00
14: 48 8b 0d 00 00 00 00 mov 0x0(%rip),%rcx # 1b <_Dmain+0x1b>
1b: 83 04 08 09 addl $0x9,(%rax,%rcx,1)
1f: ff ca dec %edx
21: 85 d2 test %edx,%edx
23: 75 e6 jne b <_Dmain+0xb>
Может быть, компилятор мог бы быть еще более умным и кешировать поток локальным перед циклом в регистр и возвращать его в поток локальный в конце (интересно сравнить с компилятором gdc), но даже сейчас вопросы очень хороши, IMHO.
Нужно быть очень осторожным в интерпретации результатов тестов. Например, недавний поток в группах новостей D пришел к выводу, что генерация кода в dmd вызывала значительное замедление в цикле, который выполнял арифметику, но на самом деле затраченное время определялось вспомогательной функцией времени выполнения, которая выполняла длинное деление. Генерация кода компилятора не имеет ничего общего с замедлением.
Чтобы увидеть, какой код генерируется для tls, скомпилируйте и obj2asm этот код:
__thread int x;
int foo() { return x; }
TLS реализован совсем иначе в Windows, чем в Linux, и снова будет сильно отличаться в OSX. Но во всех случаях это будет намного больше инструкций, чем простая загрузка статической области памяти. TLS всегда будет медленным по сравнению с простым доступом. Доступ к глобальным TLS в тесном цикле тоже будет медленным. Попробуйте вместо этого кэшировать значение TLS во временном.
Я написал код распределения пула потоков несколько лет назад и кэшировал дескриптор TLS в пуле, который работал хорошо.
Я разработал многозадачные системы для встраиваемых систем, и концептуально ключевым требованием для локального хранилища потоков является наличие метода переключения контекста для сохранения / восстановления указателя на локальное хранилище потоков вместе с регистрами ЦП и всем остальным, что сохраняет / восстанавливает. Для встроенных систем, которые всегда будут запускать один и тот же набор кода после запуска, проще всего сохранить / восстановить один указатель, который указывает на блок фиксированного формата для каждого потока. Хороший, чистый, легкий и эффективный.
Такой подход работает хорошо, если не возражают против наличия пространства для каждой локальной переменной потока, выделенной в каждом потоке - даже тех, которые фактически никогда не используют его - и если все, что будет в пределах блока хранения локального потока, может быть определяется как одна структура. В этом сценарии доступ к локальным переменным потока может быть почти таким же быстрым, как и доступ к другим переменным, единственное отличие заключается в дополнительном разыменовании указателя. К сожалению, многие приложения для ПК требуют чего-то более сложного.
На некоторых платформах для ПК потоку будет выделено пространство для статических переменных потока, только если в этом потоке запущен модуль, использующий эти переменные. Хотя иногда это может быть выгодно, это означает, что в разных потоках локальное хранилище часто размещается по-разному. Следовательно, может быть необходимым, чтобы потоки имели какой-то поисковый индекс, в котором находятся их переменные, и направляли все обращения к этим переменным через этот индекс.
Я ожидаю, что, если инфраструктура выделяет небольшой объем памяти фиксированного формата, может быть полезно сохранить кэш последних 1-3 локальных переменных потока, к которым обращались, поскольку во многих сценариях даже кэш с одним элементом может предложить довольно высокий рейтинг хитов.
Если вы не можете использовать поддержку TLS компилятора, вы можете сами управлять TLS. Я создал шаблон оболочки для C++, поэтому его легко заменить базовой реализацией. В этом примере я реализовал его для Win32. Примечание. Поскольку вы не можете получить неограниченное количество индексов TLS на процесс (по крайней мере, в Win32), вам следует указывать на блоки кучи, достаточно большие для хранения всех данных, специфичных для потока. Таким образом, у вас есть минимальное количество индексов TLS и связанных запросов. В "лучшем случае" у вас будет только 1 указатель TLS, указывающий на один частный блок кучи на поток.
В двух словах: не указывайте на отдельные объекты, вместо этого указывайте на конкретные потоки, кучу памяти / контейнеры, содержащие указатели объектов, для достижения лучшей производительности.
Не забудьте освободить память, если она не используется снова. Я делаю это, оборачивая поток в класс (как это делает Java) и обрабатывая TLS с помощью конструктора и деструктора. Кроме того, я храню часто используемые данные, такие как дескрипторы потоков и идентификаторы, в качестве членов класса.
использование:
для типа *: tl_ptr<тип>
для константного типа *: tl_ptr<константный тип>
для типа * const: const tl_ptr<тип>
тип const * const: const tl_ptr<тип const>
template<typename T>
class tl_ptr {
protected:
DWORD index;
public:
tl_ptr(void) : index(TlsAlloc()){
assert(index != TLS_OUT_OF_INDEXES);
set(NULL);
}
void set(T* ptr){
TlsSetValue(index,(LPVOID) ptr);
}
T* get(void)const {
return (T*) TlsGetValue(index);
}
tl_ptr& operator=(T* ptr){
set(ptr);
return *this;
}
tl_ptr& operator=(const tl_ptr& other){
set(other.get());
return *this;
}
T& operator*(void)const{
return *get();
}
T* operator->(void)const{
return get();
}
~tl_ptr(){
TlsFree(index);
}
};
Мы видели похожие проблемы с производительностью от TLS (на Windows). Мы полагаемся на это для определенных критических операций внутри "ядра" нашего продукта. После некоторых усилий я решил попытаться улучшить это.
Я рад сообщить, что теперь у нас есть небольшой API, который предлагает> 50% -ное сокращение процессорного времени для эквивалентной операции, когда поток вызова не "знает" свой идентификатор потока, и> 65% -ное сокращение, если вызывающий поток уже получил идентификатор потока (возможно, для какого-то другого более раннего этапа обработки).
Новая функция ( get_thread_private_ptr()) всегда возвращает указатель на структуру, которую мы используем внутренне для хранения всех сортировок, поэтому нам нужен только один на поток.
В целом, я думаю, что поддержка Win32 TLS на самом деле плохо разработана.