Масштабируемое выделение больших (8 МБ) областей памяти на архитектурах NUMA


В настоящее время мы используем потоковый график TBB, в котором a) параллельный фильтр обрабатывает массив (параллельно со смещениями) и помещает обработанные результаты в промежуточный вектор (выделенный в куче; в основном вектор будет расти до 8 МБ). Эти векторы затем передаются узлам, которые затем обрабатывают эти результаты на основе их характеристик (определенных В а)). Из-за синхронизированных ресурсов для каждой характеристики может быть только один такой узел. Прототип, который мы написали, работает хорошо на архитектурах UMA (тестируется на одном процессоре Ivy Bridge и Sandy Bridge architecture). Однако приложение не масштабируется на нашей архитектуре NUMA (4 CPU Nehalem-EX). Мы связали проблему с выделением памяти и создали минимальный пример, в котором у нас есть параллельный конвейер, который просто выделяет память из кучи (через malloc 8MB chunk, затем memset 8MB region; аналогично тому, что делал бы первоначальный прототип) до определенного объема памяти. Наши результаты являются:

  • На архитектуре UMA приложение масштабируется линейно с количеством потоков, используемых конвейером (задается через task_scheduler_init)

  • В архитектуре NUMA, когда мы прикрепляем приложение к одному сокету (используя numactl), мы видим то же самое линейное масштабирование

  • На архитектуре NUMA, когда мы используем более одного сокета, время выполнения нашего приложения увеличивается с увеличением числа сокетов (отрицательная линейная масштаб-вверх")

Для нас это пахнет кучей раздоров. То, что мы до сих пор пытались заменить Интел"с ТББ масштабируемой распределителя для распределителя с glibc. Однако начальная производительность на одном сокете хуже, чем при использовании glibc, на нескольких сокетах производительность не ухудшается, но и не улучшается. Мы получили тот же эффект, используя tcmalloc, распределитель хранилища и распределитель выравнивания кэша TBB.

Вопрос в том, испытывал ли кто-то подобные проблемы. Выделение стека-это не вариант для нас, поскольку мы хотим сохранить векторы, выделенные в куче, даже после запуска конвейера. Как одна куча может эффективно распределять области памяти размером с МБС на архитектурах NUMA из нескольких потоков? Мы действительно хотели бы сохранить динамический подход к распределению вместо предварительного распределения памяти и управления ею в приложении.

Я прикрепил статистику perf для различных исполнений с помощью numactl. Чередование/localalloc не имеет никакого эффекта вообще (шина QPI не узкое место; мы проверили, что с PCM, QPI link load составляет 1%). Я также добавил диаграмму, отображающую результаты для glibc, tbbmalloc и tcmalloc.

Perf stat bin / прототип 598.867

Статистика счетчика производительности для 'bin / prototype':

  12965,118733 task-clock                #    7,779 CPUs utilized          
        10.973 context-switches          #    0,846 K/sec                  
         1.045 CPU-migrations            #    0,081 K/sec                  
       284.210 page-faults               #    0,022 M/sec                  
17.266.521.878 cycles                    #    1,332 GHz                     [82,84%]
15.286.104.871 stalled-cycles-frontend   #   88,53% frontend cycles idle    [82,84%]
10.719.958.132 stalled-cycles-backend    #   62,09% backend  cycles idle    [67,65%]
 3.744.397.009 instructions              #    0,22  insns per cycle        
                                         #    4,08  stalled cycles per insn [84,40%]
   745.386.453 branches                  #   57,492 M/sec                   [83,50%]
    26.058.804 branch-misses             #    3,50% of all branches         [83,33%]

   1,666595682 seconds time elapsed

Perf stat numactl --cpunodebind=0 bin / prototype 272.614

Статистика счетчика производительности для 'numactl --cpunodebind=0 bin / prototype':

   3887,450198 task-clock                #    3,345 CPUs utilized          
         2.360 context-switches          #    0,607 K/sec                  
           208 CPU-migrations            #    0,054 K/sec                  
       282.794 page-faults               #    0,073 M/sec                  
 8.472.475.622 cycles                    #    2,179 GHz                     [83,66%]
 7.405.805.964 stalled-cycles-frontend   #   87,41% frontend cycles idle    [83,80%]
 6.380.684.207 stalled-cycles-backend    #   75,31% backend  cycles idle    [66,90%]
 2.170.702.546 instructions              #    0,26  insns per cycle        
                                         #    3,41  stalled cycles per insn [85,07%]
   430.561.957 branches                  #  110,757 M/sec                   [82,72%]
    16.758.653 branch-misses             #    3,89% of all branches         [83,06%]

   1,162185180 seconds time elapsed

Perf stat numactl -- cpunodebind=0-1 бункер / прототип 356.726

Статистика счетчика производительности для 'numactl --cpunodebind=0-1 bin / prototype':

   6127,077466 task-clock                #    4,648 CPUs utilized          
         4.926 context-switches          #    0,804 K/sec                  
           469 CPU-migrations            #    0,077 K/sec                  
       283.291 page-faults               #    0,046 M/sec                  
10.217.787.787 cycles                    #    1,668 GHz                     [82,26%]
 8.944.310.671 stalled-cycles-frontend   #   87,54% frontend cycles idle    [82,54%]
 7.077.541.651 stalled-cycles-backend    #   69,27% backend  cycles idle    [68,59%]
 2.394.846.569 instructions              #    0,23  insns per cycle        
                                         #    3,73  stalled cycles per insn [84,96%]
   471.191.796 branches                  #   76,903 M/sec                   [83,73%]
    19.007.439 branch-misses             #    4,03% of all branches         [83,03%]

   1,318087487 seconds time elapsed

Perf stat numactl --cpunodebind=0-2 bin / protoype 472.794

Статистика счетчика производительности для 'numactl --cpunodebind=0-2 bin / prototype':

   9671,244269 task-clock                #    6,490 CPUs utilized          
         7.698 context-switches          #    0,796 K/sec                  
           716 CPU-migrations            #    0,074 K/sec                  
       283.933 page-faults               #    0,029 M/sec                  
14.050.655.421 cycles                    #    1,453 GHz                     [83,16%]
12.498.787.039 stalled-cycles-frontend   #   88,96% frontend cycles idle    [83,08%]
 9.386.588.858 stalled-cycles-backend    #   66,81% backend  cycles idle    [66,25%]
 2.834.408.038 instructions              #    0,20  insns per cycle        
                                         #    4,41  stalled cycles per insn [83,44%]
   570.440.458 branches                  #   58,983 M/sec                   [83,72%]
    22.158.938 branch-misses             #    3,88% of all branches         [83,92%]

   1,490160954 seconds time elapsed

Минимальный пример: компилируется с g++-4.7 std=c++11-O3-march=native; выполняется с numactl --cpunodebind=0 ... numactl --cpunodebind=0-3-с привязкой CPU мы имеем следующий вывод: 1 CPU (скорость x), 2 CPU (скорость ~ x / 2), 3 процессора (скорость ~ x/3) [скорость=чем выше, тем лучше]. Итак, мы видим, что производительность ухудшается с увеличением числа процессоров. Привязка памяти, перемежение (--interleave=all) и --localalloc здесь не действуют (мы отслеживали все ссылки QPI, и загрузка ссылок была ниже 1% для каждой ссылки).

#include <tbb/pipeline.h>
#include <tbb/task_scheduler_init.h>
#include <chrono>
#include <stdint.h>
#include <iostream>
#include <fcntl.h>
#include <sstream>
#include <sys/mman.h>
#include <tbb/scalable_allocator.h>
#include <tuple>

namespace {
// 8 MB
size_t chunkSize = 8 * 1024 * 1024;
// Number of threads (0 = automatic)
uint64_t threads=0;
}

using namespace std;
typedef chrono::duration<double, milli> milliseconds;

int main(int /* argc */, char** /* argv */)
{
   chrono::time_point<chrono::high_resolution_clock> startLoadTime = chrono::high_resolution_clock::now();
   tbb::task_scheduler_init init(threads==0?tbb::task_scheduler_init::automatic:threads);
   const uint64_t chunks=128;
   uint64_t nextChunk=0;
   tbb::parallel_pipeline(128,tbb::make_filter<void,uint64_t>(
         tbb::filter::serial,[&](tbb::flow_control& fc)->uint64_t
   {
      uint64_t chunk=nextChunk++;
      if(chunk==chunks)
         fc.stop();

      return chunk;
   }) & tbb::make_filter<uint64_t,void>(
         tbb::filter::parallel,[&](uint64_t /* item */)->void
   {
        void* buffer=scalable_malloc(chunkSize);
        memset(buffer,0,chunkSize);
   }));

   chrono::time_point<chrono::high_resolution_clock> endLoadTime = chrono::high_resolution_clock::now();
   milliseconds loadTime = endLoadTime - startLoadTime;
   cout << loadTime.count()<<endl;
}

Обсуждение на форумах Intel TBB: http://software.intel.com/en-us/forums/topic/346334

2 22

2 ответа:

Краткое обновление и частичный ответ на описанную проблему: Вызов malloc или scalable_malloc не является узким местом, узким местом являются скорее ошибки страницы, вызванные memset ting выделенной памяти. Нет никакой разницы между glibc malloc и другими масштабируемыми распределителями, такими как Intel TBB scalable_malloc: для распределений, превышающих определенный порог (обычно 1 Мб, если ничего не является free d; может быть определено madvise), память будет выделена анонимным mmap. Изначально все страницы карты укажите на внутреннюю страницу ядра, которая предварительно настроена и доступна только для чтения. Когда мы меняем память, это вызывает исключение (имейте в виду, что страница ядра доступна только для чтения) и ошибку страницы. В это время будет открыта новая страница. Маленькие страницы имеют размер 4 КБ, поэтому это произойдет 2048 раз для буфера 8 МБ, который мы выделяем и записываем. Я измерил, что эти ошибки страницы не так дороги на машинах с одним сокетом, но становятся все более и более дорогими на машинах NUMA с несколькими процессорами.

Решения я придумал так далеко:

  • Используйте огромные страницы: помогает, Но только задерживает проблему

  • Используйте предварительно распределенный и предварительно сброшенный (либо memset, либо mmap + MAP_POPULATE) область памяти (пул памяти) и выделять оттуда: помогает, Но не обязательно хочет этого делать

  • Решите эту проблему масштабируемости в ядре Linux

Второе обновление (закрытие вопроса):

Только что снова профилировал пример приложения с ядром 3.10.

Результаты для параллельного распределения и memsetting 16GB данных:

Маленькие страницы:

  • 1 гнездо: 3112.29 МС
  • 2 сокет: 2965,32 МС
  • 3 сокет: 3000.72 МС
  • 4 сокет: 3211.54 МС

Огромные страницы:

  • 1 сокет: 3086.77 МС
  • 2 сокет: 1568.43 МС
  • 3 гнездо: 1084.45 МС
  • 4 сокет: 852.697 МС

Проблема масштабируемого распределения, похоже, теперь исправлена - по крайней мере, для огромных страниц.