Почему foreach% dopar% становится медленнее с каждым дополнительным node?

Я написал простое матричное умножение для тестирования возможностей многопоточности/параллелизации моей сети, и я заметил, что вычисление было намного медленнее, чем ожидалось.

Тест прост: умножьте 2 матрицы (4096x4096) и верните время вычисления. Ни матрицы, ни результаты не сохраняются. Время вычисления не является тривиальным (50-90 секунд в зависимости от вашего процессора).

Условия: я повторил это вычисление 10 раз с использованием 1 процессора, разделил эти 10 вычислений на 2 процессора (по 5 штук), затем 3 процессора,... до 10 процессоров (1 вычисление до каждый процессор). Я ожидал, что общее время вычисления уменьшится поэтапно, и я ожидал, что 10 процессоров завершат вычисления 10 раз так быстро, как требуется одному процессору сделать то же самое.

Результаты. Вместо этого я получил только 2-кратное сокращение времени вычисления, которое в 5 раз SLOWER, чем ожидалось.

введите описание изображения здесь

Когда я вычислил среднее время вычисления на node, я ожидал, что каждый процессор вычислит тест за такое же время (в среднем) независимо от количества назначенных процессоров. Я был удивлен, увидев, что просто отправка одной операции на несколько процессоров замедляет среднее время вычисления каждого процессора.

введите описание изображения здесь

Может ли кто-нибудь объяснить, почему это происходит?

Обратите внимание, что это вопрос НЕ дубликат этих вопросов:

foreach% dopar% медленнее, чем для цикла

или

Почему параллельный пакет работает медленнее, чем просто использовать?

Поскольку тестовое вычисление не является тривиальным (т.е. 50-90 сек. не 1-2 сек.) и потому, что между обработанными процессорами я не вижу связи (т.е. результаты не возвращаются или не сохраняются иначе, чем время вычисления).

Я добавил скрипты и функции для репликации.

library(foreach); library(doParallel);library(data.table)
# functions adapted from
# http://www.bios.unc.edu/research/genomic_software/Matrix_eQTL/BLAS_Testing.html

Matrix.Multiplier <- function(Dimensions=2^12){
  # Creates a matrix of dim=Dimensions and runs multiplication
  #Dimensions=2^12
  m1 <- Dimensions; m2 <- Dimensions; n <- Dimensions;
  z1 <- runif(m1*n); dim(z1) = c(m1,n)
  z2 <- runif(m2*n); dim(z2) = c(m2,n)
  a <- proc.time()[3]
  z3 <- z1 %*% t(z2)
  b <- proc.time()[3]
  c <- b-a
  names(c) <- NULL
  rm(z1,z2,z3,m1,m2,n,a,b);gc()
  return(c)
}

Nodes <- 10
Results <- NULL
for(i in 1:Nodes){
  cl <- makeCluster(i)
  registerDoParallel(cl)
  ptm <- proc.time()[3]
  i.Node.times <- foreach(z=1:Nodes,.combine="c",.multicombine=TRUE, 
                          .inorder=FALSE) %dopar% {
                            t <- Matrix.Multiplier(Dimensions=2^12)
                          }
  etm <- proc.time()[3]
  i.TotalTime <- etm-ptm
  i.Times <- cbind(Operations=Nodes,Node.No=i,Avr.Node.Time=mean(i.Node.times),
                   sd.Node.Time=sd(i.Node.times),
                   Total.Time=i.TotalTime)
  Results <- rbind(Results,i.Times)
  rm(ptm,etm,i.Node.times,i.TotalTime,i.Times)
  stopCluster(cl)
}
library(data.table)
Results <- data.table(Results)
Results[,lower:=Avr.Node.Time-1.96*sd.Node.Time]
Results[,upper:=Avr.Node.Time+1.96*sd.Node.Time]
Exp.Total <- c(Results[Node.No==1][,Avr.Node.Time]*10,
               Results[Node.No==1][,Avr.Node.Time]*5,
               Results[Node.No==1][,Avr.Node.Time]*4,
               Results[Node.No==1][,Avr.Node.Time]*3,
               Results[Node.No==1][,Avr.Node.Time]*2,
               Results[Node.No==1][,Avr.Node.Time]*2,
               Results[Node.No==1][,Avr.Node.Time]*2,
               Results[Node.No==1][,Avr.Node.Time]*2,
               Results[Node.No==1][,Avr.Node.Time]*2,
               Results[Node.No==1][,Avr.Node.Time]*1)
Results[,Exp.Total.Time:=Exp.Total]

jpeg("Multithread_Test_TotalTime_Results.jpeg")
par(oma=c(0,0,0,0)) # set outer margin to zero
par(mar=c(3.5,3.5,2.5,1.5)) # number of lines per margin (bottom,left,top,right)
plot(x=Results[,Node.No],y=Results[,Total.Time],  type="o", xlab="", ylab="",ylim=c(80,900),
     col="blue",xaxt="n", yaxt="n", bty="l")
title(main="Time to Complete 10 Multiplications", line=0,cex.lab=3)
title(xlab="Nodes",line=2,cex.lab=1.2,
      ylab="Total Computation Time (secs)")
axis(2, at=seq(80, 900, by=100), tick=TRUE, labels=FALSE)
axis(2, at=seq(80, 900, by=100), tick=FALSE, labels=TRUE, line=-0.5)
axis(1, at=Results[,Node.No], tick=TRUE, labels=FALSE)
axis(1, at=Results[,Node.No], tick=FALSE, labels=TRUE, line=-0.5)
lines(x=Results[,Node.No],y=Results[,Exp.Total.Time], type="o",col="red")
legend('topright','groups',
       legend=c("Measured", "Expected"), bty="n",lty=c(1,1),
       col=c("blue","red"))
dev.off()

jpeg("Multithread_Test_PerNode_Results.jpeg")
par(oma=c(0,0,0,0)) # set outer margin to zero
par(mar=c(3.5,3.5,2.5,1.5)) # number of lines per margin (bottom,left,top,right)
plot(x=Results[,Node.No],y=Results[,Avr.Node.Time],  type="o", xlab="", ylab="",
     ylim=c(50,500),col="blue",xaxt="n", yaxt="n", bty="l")
title(main="Per Node Multiplication Time", line=0,cex.lab=3)
title(xlab="Nodes",line=2,cex.lab=1.2,
      ylab="Computation Time (secs) per Node")
axis(2, at=seq(50,500, by=50), tick=TRUE, labels=FALSE)
axis(2, at=seq(50,500, by=50), tick=FALSE, labels=TRUE, line=-0.5)
axis(1, at=Results[,Node.No], tick=TRUE, labels=FALSE)
axis(1, at=Results[,Node.No], tick=FALSE, labels=TRUE, line=-0.5)
abline(h=Results[Node.No==1][,Avr.Node.Time], col="red")
epsilon = 0.2
segments(Results[,Node.No],Results[,lower],Results[,Node.No],Results[,upper])
segments(Results[,Node.No]-epsilon,Results[,upper],
         Results[,Node.No]+epsilon,Results[,upper])
segments(Results[,Node.No]-epsilon, Results[,lower],
         Results[,Node.No]+epsilon,Results[,lower])
legend('topleft','groups',
       legend=c("Measured", "Expected"), bty="n",lty=c(1,1),
       col=c("blue","red"))
dev.off()

РЕДАКТИРОВАТЬ: Ответ @Hong Ooi comment

Я использовал lscpu в UNIX для получения;

Architecture:          x86_64
CPU op-mode(s):        32-bit, 64-bit
Byte Order:            Little Endian
CPU(s):                30
On-line CPU(s) list:   0-29
Thread(s) per core:    1
Core(s) per socket:    1
Socket(s):             30
NUMA node(s):          4
Vendor ID:             GenuineIntel
CPU family:            6
Model:                 63
Model name:            Intel(R) Xeon(R) CPU E5-2630 v3 @ 2.40GHz
Stepping:              2
CPU MHz:               2394.455
BogoMIPS:              4788.91
Hypervisor vendor:     VMware
Virtualization type:   full
L1d cache:             32K
L1i cache:             32K
L2 cache:              256K
L3 cache:              20480K
NUMA node0 CPU(s):     0-7
NUMA node1 CPU(s):     8-15
NUMA node2 CPU(s):     16-23
NUMA node3 CPU(s):     24-29

EDIT: ответ на комментарий @Steve Weston.

Я использую сеть виртуальных машин (но я не администратор) с доступом до 30 кластеров. Я провела тест, который вы предложили. Открыл 5 сеансов R и одновременно выполнял матричное умножение на 1,2... 5 (или так быстро, как я мог накладывать и выполнять). Получены очень похожие результаты до (повторный: каждый дополнительный процесс замедляет все отдельные сеансы). Обратите внимание, что я проверил использование памяти с помощью top и htop, и использование никогда не превышало 5% от емкости сети (~ 2.5/64Gb).

введите описание изображения здесь

Заключение:

Проблема, похоже, специфична для R. Когда я запускаю другие многопоточные команды с другим программным обеспечением (например, PLINK), я не сталкиваюсь с этой проблемой, и параллельный процесс выполняется как ожидается. Я также попробовал запустить выше с Rmpi и doMPI с такими же (более медленными) результатами. По-видимому, проблема связана с R сеансами/параллельными командами в сети виртуальных машин. Мне очень нужна помощь, чтобы точно определить проблему. Аналогичная проблема, кажется, указана здесь

Ответы

Ответ 1

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

Я могу думать о двух причинах, почему это может произойти:

  • Полоса пропускания памяти машины насыщена матричными умножениями до того, как вы закончите работу с ядрами;
  • Матричное умножение многопоточно.

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

> x <- matrix(rnorm(4096*4096), 4096)
> y <- matrix(rnorm(4096*4096), 4096)

а затем выполнить умножение матрицы в каждом из этих сеансов примерно в одно и то же время:

> system.time(z <- x %*% t(y))

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

Если ваша установка R была построена с помощью многопоточной математической библиотеки, такой как MKL или ATLAS, то вы могли бы использовать все свои ядра с одним умножением матрицы, поэтому вы не можете ожидать повышения производительности за счет использования нескольких процессов если вы не используете несколько компьютеров.

Вы можете использовать такой инструмент, как "верх", чтобы узнать, используете ли вы многопоточную математическую библиотеку.

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


Update

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

Я думаю, что это просто ограничение использования одного компьютера для параллельных вычислений. Одним из преимуществ использования кластера является то, что пропускная способность вашей памяти увеличивается, а также общая совокупная память. Поэтому, если вы выполняли одно или два умножения матрицы на каждую node параллельной программы с несколькими node, вы не столкнулись бы с этой конкретной проблемой.

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

Вы также можете попробовать использовать GPU. Очевидно, что они умеют выполнять умножения матриц.


Обновление 2

Чтобы определить, является ли проблема конкретным значением R, я предлагаю вам выполнить проверку функции dgemm, которая является функцией BLAS, используемой R, для реализации умножения матрицы.

Вот простая программа Fortran для тестирования dgemm. Я предлагаю выполнить его с нескольких терминалов так же, как я описал для сравнения %*% в R:

      program main
      implicit none
      integer n, i, j
      integer*8 stime, etime
      parameter (n=4096)
      double precision a(n,n), b(n,n), c(n,n)
      do i = 1, n
        do j = 1, n
          a(i,j) = (i-1) * n + j
          b(i,j) = -((i-1) * n + j)
          c(i,j) = 0.0d0
        end do
      end do
      stime = time8()
      call dgemm('N','N',n,n,n,1.0d0,a,n,b,n,0.0d0,c,n)
      etime = time8()
      print *, etime - stime
      end

На моей машине Linux один экземпляр работает через 82 секунды, а четыре экземпляра запускаются за 116 секунд. Это согласуется с результатами, которые я вижу в R, и с моей догадкой, что это проблема с пропускной способностью памяти.

Вы также можете связать это с различными библиотеками BLAS, чтобы увидеть, какая реализация лучше работает на вашем компьютере.

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

Ответ 2

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

Вместо этого вы умножаете две матрицы. Поскольку умножение каждой матрицы, по-видимому, обрабатывается только одним ядром, каждое ядро, превышающее два, является просто непростым служебным. В результате вы видите только улучшение скорости 2x.

Вы можете проверить это, выполнив более двух матричных умножений. Но я не знаком с инфраструктурой foreach, doParallel (я использую parallel), и не вижу, где в вашем коде, чтобы изменить это, чтобы проверить его.

Альтернативным тестом является параллельная версия матричного умножения, которую я беру непосредственно из Matloff Parallel Computing for Data Science. Проект доступен здесь, см. Стр. 27

mmulthread <- function(u, v, w) {
  require(parallel)
  # determine which rows for this thread
  myidxs <- splitIndices(nrow(u), myinfo$nwrkrs ) [[ myinfo$id ]]
  # compute this thread portion of the result
  w[myidxs, ] <- u [myidxs, ] %*% v [ , ]
  0 # dont return result -- expensive
}
# t e s t on snow c l u s t e r c l s
test <- function (cls,  n = 2^5) {
  # i n i t Rdsm
  mgrinit(cls)
  # shared variables
  mgrmakevar(cls, "a", n, n)
  mgrmakevar(cls, "b", n, n)
  mgrmakevar(cls, "c", n, n)
  # f i l l i n some t e s t data
  a [ , ] <- 1:n
  b [ , ] <- rep (1 ,n)

  # export function
  clusterExport(cls , "mmulthread" )
  # run function
  clusterEvalQ(cls , mmulthread (a ,b ,c ))
  #print ( c[ , ] ) # not p ri n t ( c ) !
}


library(parallel)
library(Rdsm)

c1 <- makeCluster(1)
c2 <- makeCluster (2)
c4 <- makeCluster(4)
c8 <- makeCluster(8)

library(microbenchmark)

microbenchmark(node1= test(c1, n= 2^10),
           node2= test(c2, n= 2^10),
           node4= test(c4, n= 2^10),
           node8= test(c8, n= 2^10))



 Unit: milliseconds
  expr      min       lq     mean   median       uq      max neval  cld
 node1 715.8722 780.9861 818.0487 817.6826 847.5353 922.9746   100    d
 node2 404.9928 422.9330 450.9016 437.5942 458.9213 589.1708   100   c 
 node4 255.3105 285.8409 309.5924 303.6403 320.8424 481.6833   100 a   
 node8 304.6386 328.6318 365.5114 343.0939 373.8573 836.2771   100  b  

Как и ожидалось, распараллеливая матричное умножение, мы видим улучшение затрат, которое мы хотели, хотя параллельные накладные расходы явно обширны.

Ответ 3

Думаю, здесь уже был дан ответ? foreach% dopar% медленнее, чем для цикла

Я имею в виду его немного экстраполяции того же ответа. Таким образом, принципиальная концепция остается постоянной.

Вот еще один пример, когда процесс происходит параллельно (последовательно или вне последовательности). Ответ не может быть объединен. Его простое повторное использование того же результата, отбрасывая одну и ту же переменную. (Точно как простой для цикла). Но все же цикл замедляется в doParallel Почему foreach()% do% иногда медленнее, чем для?