Почему локальное хранилище потоков так медленно?

Я работаю над пользовательским распределителем памяти стиля выпуска метки для языка программирования 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 на самом деле плохо разработана.

Другие вопросы по тегам