Scala: производительность изменяемого и неизменного объекта - OutOfMemoryError - PullRequest
16 голосов
/ 21 августа 2009

Я хотел сравнить характеристики производительности immutable.Map и mutable.Map в Scala для аналогичной операции (а именно, объединения многих карт в одну. См. этот вопрос ). У меня есть то, что кажется похожими реализациями как для изменчивых, так и для неизменяемых карт (см. Ниже).

В качестве теста я сгенерировал список, содержащий 1 000 000 одноэлементных карт [Int, Int], и передал этот список в функции, которые я тестировал. При достаточном объеме памяти результаты были неудивительными: ~ 1200 мс для mutable.Map, ~ 1800 мс для immutable.Map и ~ 750 мс для обязательной реализации с использованием mutable.Map - не уверен, что объясняет огромную разницу, но не стесняйтесь прокомментируйте это тоже.

Что меня немного удивило, возможно, из-за того, что я немного утончен, так это то, что при конфигурации запуска по умолчанию в IntelliJ 8.1 обе изменяемые реализации сталкиваются с ошибкой OutOfMemoryError, но неизменная коллекция - нет. Неизменяемый тест выполнялся до конца, но он выполнялся очень медленно - это занимает около 28 секунд. Когда я увеличил максимальную память JVM (примерно до 200 МБ, не знаю, где находится порог), я получил результаты выше.

В любом случае, вот что я действительно хочу знать:

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

Реализация ниже. (Примечание: я не утверждаю, что это лучшие из возможных реализаций. Не стесняйтесь предлагать улучшения.)

  def mergeMaps[A,B](func: (B,B) => B)(listOfMaps: List[Map[A,B]]): Map[A,B] =
    (Map[A,B]() /: (for (m <- listOfMaps; kv <-m) yield kv)) { (acc, kv) =>
      acc + (if (acc.contains(kv._1)) kv._1 -> func(acc(kv._1), kv._2) else kv)
    }

  def mergeMutableMaps[A,B](func: (B,B) => B)(listOfMaps: List[mutable.Map[A,B]]): mutable.Map[A,B] =
    (mutable.Map[A,B]() /: (for (m <- listOfMaps; kv <- m) yield kv)) { (acc, kv) =>
      acc + (if (acc.contains(kv._1)) kv._1 -> func(acc(kv._1), kv._2) else kv)
    }

  def mergeMutableImperative[A,B](func: (B,B) => B)(listOfMaps: List[mutable.Map[A,B]]): mutable.Map[A,B] = {
    val toReturn = mutable.Map[A,B]()
    for (m <- listOfMaps; kv <- m) {
      if (toReturn contains kv._1) {
        toReturn(kv._1) = func(toReturn(kv._1), kv._2)
      } else {
        toReturn(kv._1) = kv._2
      }
    }
    toReturn
  }

1 Ответ

24 голосов
/ 21 августа 2009

Ну, это действительно зависит от того, какой тип карты вы используете. Вероятно HashMap. Теперь такие изменяемые структуры повышают производительность, предварительно выделяя память, которую они ожидают использовать. Вы присоединяетесь к миллиону карт, поэтому окончательная карта должна быть несколько большой. Давайте посмотрим, как эти ключи / значения добавляются:

protected def addEntry(e: Entry) { 
  val h = index(elemHashCode(e.key)) 
  e.next = table(h).asInstanceOf[Entry] 
  table(h) = e 
  tableSize = tableSize + 1 
  if (tableSize > threshold) 
    resize(2 * table.length) 
} 

Видите 2 * в строке resize? Изменяемый HashMap увеличивается вдвое каждый раз, когда ему не хватает места, в то время как неизменяемый довольно консервативный в использовании памяти (хотя существующие ключи обычно занимают вдвое больше места при обновлении).

Теперь, что касается других проблем с производительностью, вы создаете список ключей и значений в первых двух версиях. Это означает, что перед тем, как вы присоединитесь к каким-либо картам, у вас уже есть каждый Tuple2 (пары ключ / значение) в памяти дважды! Плюс накладные расходы на List, которые невелики, но речь идет о более чем одном миллионе элементов, умноженных на накладные расходы.

Вы можете использовать проекцию, которая этого избегает. К сожалению, проекция основана на Stream, что не очень надежно для наших целей на Scala 2.7.x. Тем не менее, попробуйте это вместо:

for (m <- listOfMaps.projection; kv <- m) yield kv

A Stream не вычисляет значение, пока оно не потребуется. Сборщик мусора должен также собирать неиспользуемые элементы, если вы не сохраняете ссылку на заголовок Stream, что, как кажется, имеет место в вашем алгоритме.

EDIT

В дополнение, понимание for / yield берет одну или несколько коллекций и возвращает новую коллекцию. Как часто это имеет смысл, возвращающая коллекция имеет тот же тип, что и исходная коллекция. Так, например, в следующем коде for-compreatsion создает новый список, который затем сохраняется внутри l2. Это не val l2 =, который создает новый список, но для понимания.

val l = List(1,2,3)
val l2 = for (e <- l) yield e*2

Теперь давайте посмотрим на код, используемый в первых двух алгоритмах (без ключевого слова mutable):

(Map[A,B]() /: (for (m <- listOfMaps; kv <-m) yield kv)) 

Оператор foldLeft, здесь записанный с его синонимом /:, будет вызываться для объекта, возвращаемого для понимания. Помните, что : в конце оператора инвертирует порядок объекта и параметров.

Теперь давайте рассмотрим, что это за объект, для которого вызывается foldLeft. Первый генератор этого понимания - m <- listOfMaps. Мы знаем, что listOfMaps - это коллекция типа List [X], где X на самом деле не имеет значения. Результат для понимания на List всегда является другим List. Другие генераторы не имеют отношения.

Итак, вы берете это List, получаете все ключи / значения внутри каждого Map, который является компонентом этого List, и создаете новый List со всем этим. Вот почему вы дублируете все, что у вас есть.

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

Следующий вопрос - фактически первый, но его было проще перевернуть, - как помогает использование projection.

Когда вы вызываете projection для List, он возвращает новый объект типа Stream (в Scala 2.7.x). Сначала вы можете подумать, что это только ухудшит ситуацию, потому что теперь у вас будет три копии List вместо одной. Но Stream предварительно не вычисляется. лениво вычислено.

Это означает, что результирующий объект, Stream, не является копией List, а скорее функцией, которая может использоваться для вычисления Stream при необходимости. После вычисления результат будет сохранен, так что его не нужно будет снова вычислять.

Кроме того, map, flatMap и filter из Stream все возвращают новый Stream, что означает, что вы можете объединить их в цепочку без создания единственной копии List, которая их создала , Поскольку для-понимания с yield используются именно эти функции, использование Stream внутри предотвращает ненужные копии данных.

Теперь предположим, что вы написали что-то вроде этого:

val kvs = for (m <- listOfMaps.projection; kv <-m) yield kv
(Map[A,B]() /: kvs) { ... }

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

Теперь рассмотрим оригинальную форму ::

(Map[A,B]() /: (for (m <- listOfMaps.projection; kv <-m) yield kv)) 

В этом случае Stream используется одновременно с его вычислением. Давайте кратко рассмотрим, как определяется foldLeft для Stream:

override final def foldLeft[B](z: B)(f: (B, A) => B): B = { 
  if (isEmpty) z 
  else tail.foldLeft(f(z, head))(f) 
} 

Если Stream пуст, просто верните аккумулятор. В противном случае вычислите новый аккумулятор (f(z, head)), а затем передайте его и функцию в tail из Stream.

После того, как f(z, head) выполнится, ссылка на head не останется. Или, другими словами, нигде в программе ничего не будет указывать на head из Stream, и это означает, что сборщик мусора сможет его собрать, освобождая память.

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

Наконец, возникает вопрос, почему третий алгоритм не получает от этого пользы. Что ж, третий алгоритм не использует yield, поэтому никаких копий каких-либо данных не делается. В этом случае использование projection добавляет только слой косвенности.

...