Эффективная обработка списка малых векторов с помощью Compile - PullRequest
10 голосов
/ 18 ноября 2011

Часто нам нужно обрабатывать данные, состоящие из списка координат: data = {{x1,y1}, {x2,y2}, ..., {xn,yn}}. Это могут быть двухмерные или трехмерные координаты или любой другой список произвольной длины с небольшими векторами фиксированной длины.

Позвольте мне проиллюстрировать, как использовать Compile для таких задач, на простом примере суммирования списка 2D векторов:

data = RandomReal[1, {1000000, 2}];

Первая, очевидная версия:

fun1 = Compile[{{vec, _Real, 2}},
  Module[{sum = vec[[1]]},
   Do[sum += vec[[i]], {i, 2, Length[vec]}];
   sum
   ]
  ]

Как быстро это?

In[13]:= Do[fun1[data], {10}] // Timing
Out[13]= {4.812, Null}

Вторая, менее очевидная версия:

fun2 = Compile[{{vec, _Real, 1}},
  Module[{sum = vec[[1]]},
   Do[sum += vec[[i]], {i, 2, Length[vec]}];
   sum
   ]
  ]

In[18]:= Do[
  fun2 /@ Transpose[data],
  {10}
  ] // Timing

Out[18]= {1.078, Null}

Как видите, вторая версия намного быстрее. Зачем? Поскольку важнейшая операция, sum += ... - это добавление чисел в fun2, в то время как это добавление произвольной длины векторов в fun1.

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

Теперь в этом простом примере код, использующий fun2, не длиннее и не намного сложнее, чем fun1, но в общем случае вполне может быть.

Как я могу сказать Compile, что один из ее аргументов не является произвольной n*m матрицей, а является специальной n*2 или n*3, поэтому он может выполнять эту оптимизацию автоматически, а не с помощью универсальная функция сложения векторов для добавления крошечных векторов длины-2 или длины-3?


Добавление

Чтобы было яснее, что происходит, мы можем использовать CompilePrint:

CompilePrint[fun1] т

        1 argument
        5 Integer registers
        5 Tensor registers
        Underflow checking off
        Overflow checking off
        Integer overflow checking on
        RuntimeAttributes -> {}

        T(R2)0 = A1
        I1 = 2
        I0 = 1
        Result = T(R1)3

1   T(R1)3 = Part[ T(R2)0, I0]
2   I3 = Length[ T(R2)0]
3   I4 = I0
4   goto 8
5   T(R1)2 = Part[ T(R2)0, I4]
6   T(R1)4 = T(R1)3 + T(R1)2
7   T(R1)3 = CopyTensor[ T(R1)4]]
8   if[ ++ I4 < I3] goto 5
9   Return

CompilePrint[fun2] т

        1 argument
        5 Integer registers
        4 Real registers
        1 Tensor register
        Underflow checking off
        Overflow checking off
        Integer overflow checking on
        RuntimeAttributes -> {}

        T(R1)0 = A1
        I1 = 2
        I0 = 1
        Result = R2

1   R2 = Part[ T(R1)0, I0]
2   I3 = Length[ T(R1)0]
3   I4 = I0
4   goto 8
5   R1 = Part[ T(R1)0, I4]
6   R3 = R2 + R1
7   R2 = R3
8   if[ ++ I4 < I3] goto 5
9   Return

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

Ответы [ 3 ]

11 голосов
/ 18 ноября 2011

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

fun3 =
 Compile[{{vec, _Real, 2}},
   Module[{sum = vec[[1]], len = Length[vec[[1]]]},   
     Do[sum[[j]] += vec[[i, j]], {j, 1, len}, {i, 2, Length[vec]}];
     sum], CompilationTarget -> "C"]

(кстати, я думаю, что сравнение скорости будет более справедливым при компиляции в C, поскольку виртуальная машина Mathematica, например, намного сильнее препятствует вложенным циклам). Эта функция все еще медленнее, чем ваша, но примерно в 3 раза быстрее, чем fun1, для таких маленьких векторов .

Остальная часть неэффективности, я полагаю, присуща этому подходу. Тот факт, что вы можете разложить проблему на решение для сумм отдельных компонентов, делает вашу вторую функцию эффективной, потому что вы используете структурные операции, такие как Transpose, и, самое главное, это позволяет вам выжать больше инструкций из внутреннего цикла. , Потому что это самое главное - во внутреннем цикле должно быть как можно меньше инструкций. Из CompilePrint видно, что это действительно так для fun1 против fun3. В некотором смысле, вы нашли (для этой проблемы) эффективный высокоуровневый способ вручную развернуть внешний цикл (тот, что над индексом координат). В качестве альтернативы вы предложите компилятору автоматически развернуть внешний цикл на основе дополнительной информации о векторной размерности. Это похоже на правдоподобную оптимизацию, но, вероятно, еще не было реализовано для виртуальной машины Mathematica.

Отметим также, что для больших длин векторов (скажем, 20) разница между fun1 и fun2 исчезает, поскольку стоимость выделения / освобождения памяти при тензорном копировании становится незначительной по сравнению со стоимостью массового присвоения (которое все еще более эффективно реализуется при назначении вектора на вектор - возможно, потому что вы можете использовать такие вещи, как memcpy в этом случае).

В заключение, я думаю, что было бы неплохо иметь эту оптимизацию автоматической, по крайней мере, в этом конкретном случае, это своего рода низкоуровневая оптимизация, которую трудно ожидать, чтобы быть полностью автоматической - даже оптимизирующие компиляторы C не всегда выполняй это. Одна вещь, которую вы можете попробовать, это жестко закодировать длину вектора в скомпилированную функцию, затем использовать SymbolicCGenerate (из пакета CCodeGenerator`) для генерации символического C, а затем использовать ToCCodeString для генерации кода C (или любым другим способом вы используете, чтобы получить код C для скомпилированной функции), а затем попытаетесь создать и загрузить библиотеку вручную, включив все оптимизации для компилятора C с помощью параметров CreateLibrary. Будет ли это работать, я не знаю. EDIT Я на самом деле сомневаюсь, что это вообще поможет, поскольку циклы уже реализованы с goto -s для скорости при генерации кода на C, и это, вероятно, предотвратит попытку развертывания цикла компилятором.

5 голосов
/ 18 ноября 2011

Это всегда хороший вариант, чтобы найти функцию, которая делает именно то, что вы хотите.

In[50]:= fun3=Compile[{{vec,_Real,2}},Total[vec]]

Out[50]= CompiledFunction[{vec},Total[vec],-CompiledCode-]

In[51]:= Do[fun3[data],{10}]//Timing

Out[51]= {0.121982,Null}

In[52]:= fun3[data]===fun1[data]

Out[52]= True

Другой вариант, менее эффективный (* из-за транспонирования *), заключается в использовании Listable

fun4 = Compile[{{vec, _Real, 1}}, Total[vec], 
  RuntimeAttributes -> {Listable}]

In[63]:= Do[fun4[Transpose[data]],{10}]//Timing

Out[63]= {0.235964,Null}

In[64]:= Do[Transpose[data],{10}]//Timing

Out[64]= {0.133979,Null}

In[65]:= fun4[Transpose[data]]===fun1[data]

Out[65]= True
1 голос
/ 20 ноября 2011

Как я могу сказать Compile, что один из ее аргументов - это не произвольная матрица n * m, а специальная матрица n * 2 или n * 3, поэтому он может выполнять эту оптимизацию автоматически, а не с использованием общего сложения векторов функция для добавления крошечных векторов длины-2 или длины-3?

Вы пытаетесь выручить Титаник с помощью ложки!

На моей машине с Mathematica 7.0.1 ваш первый пример занимает 4 с, ваш второй - 2 с, а Total - 0,1 с. Таким образом, у вас есть объективно крайне неэффективное решение (Total в 40 раз быстрее!), И вы правильно определили и исправили один из наименее важных факторов, влияющих на низкую производительность (делая его в 2 раза быстрее).

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

Я быстро пошел по оптимизации в Mathematica, написав код для добавления двух векторных элементов за раз:

fun3 = Compile[{{vec, _Real, 2}}, 
  Module[{x = vec[[1]][[1]], y = vec[[1]][[2]]}, 
   Do[x += vec[[i]][[1]]; y += vec[[i]][[2]], {i, 2, Length[vec]}];
   {x, y}]]

Это на самом деле еще медленнее - 5,4 с.

Но это Mathematica в худшем. Mathematica полезна только когда:

  1. Вы действительно не заботитесь о производительности, или

  2. Обширная стандартная библиотека Mathematica уже включает функции (например, Total в данном конкретном случае), которые позволяют эффективно решать вашу проблему либо с помощью одного вызова, либо путем написания сценария для нескольких вызовов.

Как сказал Рубенко, Mathematica предоставляет встроенную функцию для решения этой проблемы за один вызов (Total), но она бесполезна в общем случае, о котором вы спрашиваете. Объективно, лучшее решение состоит в том, чтобы избежать основной неэффективности Mathematica, перенося вашу программу на язык, который оценивается более эффективно.

Например, наиболее наивное возможное решение в F # (скомпилированный язык, который я должен передать, но подойдет почти любой другой), - это использовать 2D-массив:

let xs =
  let rand = System.Random()
  Array2D.init 1000000 2 (fun _ _ -> rand.NextDouble())

let sum (xs: float [,]) =
  let total = Array.zeroCreate (xs.GetLength 1)
  for i=0 to xs.GetLength 0 - 1 do
    for j=0 to xs.GetLength 1 - 1 do
      total.[j] <- total.[j] + xs.[i,j]
  total

for i=1 to 10 do
  sum xs |> ignore

Это сразу в 8 раз быстрее, чем ваше самое быстрое решение! Но подождите, вы можете добиться большего, используя систему статических типов через собственный 2D-векторный тип:

[<Struct>]
type Vec =
  val x : float
  val y : float
  new(x, y) = {x=x; y=y}

  static member (+) (u: Vec, v: Vec) =
    Vec(u.x+v.x, u.y+v.y)

let xs =
  let rand = System.Random()
  Array.init 1000000 (fun _ -> Vec(rand.NextDouble(), rand.NextDouble()))

let sum (xs: Vec []) =
  let mutable u = Vec(0.0, 0.0)
  for i=1 to xs.Length-1 do
    u <- u + xs.[i]
  u

Это решение занимает всего 0,057 с, что в 70 раз быстрее, чем у вашего оригинала, и существенно быстрее, чем любое решение на базе Mathematica, опубликованное здесь до сих пор! Язык, скомпилированный для эффективного кода SSE, вероятно, будет еще лучше.

Вы можете подумать, что 70x - это необычный особый случай, но я видел много примеров, когда перенос кода Mathematica на другие языки давал огромные ускорения:

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...