Эмпирическая оценка эффективности времени - PullRequest
39 голосов
/ 12 января 2012

Фон

Я бы хотел оценить производительность некоторых методов в библиотеке с помощью тестов производительности. Мне не нужна точность - достаточно показать, что что-то есть O (1), O (logn), O (n), O (nlogn), O (n ^ 2) или что-то еще хуже. Поскольку big-oh означает верхнюю границу, оценка O (logn) для чего-то, что является O (log logn), не является проблемой.

Сейчас я подумываю найти постоянный множитель k, который наилучшим образом соответствует данным для каждого big-oh (но превысит все результаты), а затем выбрать big-oh с наилучшим соответствием.

Вопросы

  1. Есть ли лучшие способы сделать это, чем я думаю? Если да, то каковы они?
  2. В противном случае, может ли кто-нибудь указать мне на алгоритмы для оценки k для наилучшего подбора и сравнения, насколько хорошо каждая кривая соответствует данным?

Примечания и ограничения

Учитывая комментарии, мне нужно прояснить несколько вещей:

  • Это должно быть автоматизировано. Я не могу "посмотреть" на данные и сделать суждение.
  • Я собираюсь сравнить методы с несколькими n размерами. Для каждого размера n я собираюсь использовать проверенную систему эталонных тестов, которая обеспечивает надежные статистические результаты.
  • Я на самом деле знаю заранее о большинстве методов, которые будут проверены. Мое основное намерение - предоставить для них регрессионное тестирование производительности.
  • Код будет написан на Scala, и можно использовать любую свободную библиотеку Java.

Пример * * одна тысяча тридцать одна Вот один пример того, что я хочу измерить. У меня есть метод с этой подписью: def apply(n: Int): A Учитывая n, он вернет n-й элемент последовательности. Этот метод может иметь O (1), O (logn) или O (n), учитывая существующие реализации, и небольшие изменения могут заставить его использовать субоптимальную реализацию по ошибке. Или, проще, мог бы получить какой-то другой метод, который зависит от него, чтобы использовать его неоптимальную версию.

Ответы [ 9 ]

16 голосов
/ 12 января 2012

Чтобы начать, вам нужно сделать пару предположений.

  1. n велико по сравнению с любыми постоянными терминами.
  2. Вы можете эффективно рандомизировать свой вводdata
  3. Вы можете производить выборку с достаточной плотностью, чтобы получить хорошее представление о распределении времени выполнения

В частности, (3) трудно достичь в сочетании с (1).Таким образом, вы можете получить что-то с экспоненциальным наихудшим случаем, но никогда не столкнетесь с этим наихудшим случаем и, таким образом, думаете, что ваш алгоритм намного лучше, чем в среднем.

С учетом сказанного все, что вам нужно, это любая стандартная криваяпримерочная библиотека. Apache Commons Math полностью соответствует.Затем вы либо создаете функцию со всеми общими терминами, которые вы хотите проверить (например, константа, log n, n, n log n, n n, n n * n, e ^ n), либо вывозьмите журнал ваших данных и подгоните экспоненту, а затем, если вы получите показатель, не близкий к целому числу, посмотрите, дает ли подбрасывание в журнал n лучшее соответствие.

(Более подробно, если вы подходитеC*x^a для C и a, или более просто log C + a log x, вы можете получить показатель степени a, в схеме «все общие условия сразу» вы получите весовые коэффициенты для каждого термина,поэтому, если у вас есть n*n + C*n*log(n), где C большое, вы также подберете этот термин.)

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


Редактировать: Вот код Scala, который делает все это за вас.Вместо того, чтобы объяснять каждый маленький кусочек, я оставлю это вам для расследования;он реализует схему выше, используя C * x ^ a fit, и возвращает ((a, C), (нижняя граница для a, верхняя граница для a)).Границы довольно консервативны, как вы можете заметить, выполнив эту вещь несколько раз.Единицами C являются секунды (a не имеет единиц измерения), но не стоит верить, что слишком , поскольку есть некоторые накладные расходы цикла (а также некоторый шум).

class TimeLord[A: ClassManifest,B: ClassManifest](setup: Int => A, static: Boolean = true)(run: A => B) {
  @annotation.tailrec final def exceed(time: Double, size: Int, step: Int => Int = _*2, first: Int = 1): (Int,Double) = {
    var i = 0
    val elapsed = 1e-9 * {
      if (static) {
        val a = setup(size)
        var b: B = null.asInstanceOf[B]
        val t0 = System.nanoTime
        var i = 0
        while (i < first) {
          b = run(a)
          i += 1
        }
        System.nanoTime - t0
      }
      else {
        val starts = if (static) { val a = setup(size); Array.fill(first)(a) } else Array.fill(first)(setup(size))
        val answers = new Array[B](first)
        val t0 = System.nanoTime
        var i = 0
        while (i < first) {
          answers(i) = run(starts(i))
          i += 1
        }
        System.nanoTime - t0
      }
    }
    if (time > elapsed) {
      val second = step(first)
      if (second <= first) throw new IllegalArgumentException("Iteration size increase failed: %d to %d".format(first,second))
      else exceed(time, size, step, second)
    }
    else (first, elapsed)
  }

  def multibench(smallest: Int, largest: Int, time: Double, n: Int, m: Int = 1) = {
    if (m < 1 || n < 1 || largest < smallest || (n>1 && largest==smallest)) throw new IllegalArgumentException("Poor choice of sizes")
    val frac = (largest.toDouble)/smallest
    (0 until n).map(x => (smallest*math.pow(frac,x/((n-1).toDouble))).toInt).map{ i => 
      val (k,dt) = exceed(time,i)
      if (m==1) i -> Array(dt/k) else {
        i -> ( (dt/k) +: (1 until m).map(_ => exceed(time,i,first=k)).map{ case (j,dt2) => dt2/j }.toArray )
      }
    }.foldLeft(Vector[(Int,Array[Double])]()){ (acc,x) =>
      if (acc.length==0 || acc.last._1 != x._1) acc :+ x
      else acc.dropRight(1) :+ (x._1, acc.last._2 ++ x._2)
    }
  }

  def alpha(data: Seq[(Int,Array[Double])]) = {
    // Use Theil-Sen estimator for calculation of straight-line fit for exponent
    // Assume timing relationship is t(n) = A*n^alpha
    val dat = data.map{ case (i,ad) => math.log(i) -> ad.map(x => math.log(i) -> math.log(x)) }
    val slopes = (for {
      i <- dat.indices
      j <- ((i+1) until dat.length)
      (pi,px) <- dat(i)._2
      (qi,qx) <- dat(j)._2
    } yield (qx - px)/(qi - pi)).sorted
    val mbest = slopes(slopes.length/2)
    val mp05 = slopes(slopes.length/20)
    val mp95 = slopes(slopes.length-(1+slopes.length/20))
    val intercepts = dat.flatMap{ case (i,a) => a.map{ case (li,lx) => lx - li*mbest } }.sorted
    val bbest = intercepts(intercepts.length/2)
    ((mbest,math.exp(bbest)),(mp05,mp95))
  }
}

Обратите внимание, что метод multibench, как ожидается, займет около sqrt (2) n m * времени для запуска, при условии, что используются статические данные инициализации и относительно дешево по сравнению с тем, что вы используете.Вот несколько примеров с параметрами, выбранными для запуска ~ 15 с:

val tl1 = new TimeLord(x => List.range(0,x))(_.sum)  // Should be linear
// Try list sizes 100 to 10000, with each run taking at least 0.1s;
// use 10 different sizes and 10 repeats of each size
scala> tl1.alpha( tl1.multibench(100,10000,0.1,10,10) )
res0: ((Double, Double), (Double, Double)) = ((1.0075537890632216,7.061397125245351E-9),(0.8763463348353099,1.102663784225697))

val longList = List.range(0,100000)
val tl2 = new TimeLord(x=>x)(longList.apply)    // Again, should be linear
scala> tl2.alpha( tl2.multibench(100,10000,0.1,10,10) )
res1: ((Double, Double), (Double, Double)) = ((1.4534378213477026,1.1325696181862922E-10),(0.969955396265306,1.8294175293676322))

// 1.45?!  That's not linear.  Maybe the short ones are cached?
scala> tl2.alpha( tl2.multibench(9000,90000,0.1,100,1) )
res2: ((Double, Double), (Double, Double)) = ((0.9973235607566956,1.9214696731124573E-9),(0.9486294398193154,1.0365312207345019))

// Let's try some sorting
val tl3 = new TimeLord(x=>Vector.fill(x)(util.Random.nextInt))(_.sorted)
scala> tl3.alpha( tl3.multibench(100,10000,0.1,10,10) )
res3: ((Double, Double), (Double, Double)) = ((1.1713142886974603,3.882658025586512E-8),(1.0521099621639414,1.3392622111121666))
// Note the log(n) term comes out as a fractional power
// (which will decrease as the sizes increase)

// Maybe sort some arrays?
// This may take longer to run because we have to recreate the (mutable) array each time
val tl4 = new TimeLord(x=>Array.fill(x)(util.Random.nextInt), false)(java.util.Arrays.sort)
scala> tl4.alpha( tl4.multibench(100,10000,0.1,10,10) )
res4: ((Double, Double), (Double, Double)) = ((1.1216172965292541,2.2206198821180513E-8),(1.0929414090177318,1.1543697719880128))

// Let's time something slow
def kube(n: Int) = (for (i <- 1 to n; j <- 1 to n; k <- 1 to n) yield 1).sum
val tl5 = new TimeLord(x=>x)(kube)
scala> tl5.alpha( tl5.multibench(10,100,0.1,10,10) )
res5: ((Double, Double), (Double, Double)) = ((2.8456382116915484,1.0433534274508799E-7),(2.6416659356198617,2.999094292838751))
// Okay, we're a little short of 3; there's constant overhead on the small sizes

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

(Между прочим, здесь нет явного шага разогрева; надежная подгонка оценки Тейла-Сена должна сделать его ненужным для разумно больших тестов.По этой же причине я не использую никакую другую среду для тестирования: любая статистика, которая делает это, просто теряет энергию из этого теста.)


Редактируйте снова: если вы замените метод alpha на следующий:

  // We'll need this math
  @inline private[this] def sq(x: Double) = x*x
  final private[this] val inv_log_of_2 = 1/math.log(2)
  @inline private[this] def log2(x: Double) = math.log(x)*inv_log_of_2
  import math.{log,exp,pow}

  // All the info you need to calculate a y value, e.g. y = x*m+b
  case class Yp(x: Double, m: Double, b: Double) {}

  // Estimators for data order
  //   fx = transformation to apply to x-data before linear fitting
  //   fy = transformation to apply to y-data before linear fitting
  //   model = given x, slope, and intercept, calculate predicted y
  case class Estimator(fx: Double => Double, invfx: Double=> Double, fy: (Double,Double) => Double, model: Yp => Double) {}
  // C*n^alpha
  val alpha = Estimator(log, exp, (x,y) => log(y), p => p.b*pow(p.x,p.m))
  // C*log(n)*n^alpha
  val logalpha = Estimator(log, exp, (x,y) =>log(y/log2(x)), p => p.b*log2(p.x)*pow(p.x,p.m))

  // Use Theil-Sen estimator for calculation of straight-line fit
  case class Fit(slope: Double, const: Double, bounds: (Double,Double), fracrms: Double) {}
  def theilsen(data: Seq[(Int,Array[Double])], est: Estimator = alpha) = {
    // Use Theil-Sen estimator for calculation of straight-line fit for exponent
    // Assume timing relationship is t(n) = A*n^alpha
    val dat = data.map{ case (i,ad) => ad.map(x => est.fx(i) -> est.fy(i,x)) }
    val slopes = (for {
      i <- dat.indices
      j <- ((i+1) until dat.length)
      (pi,px) <- dat(i)
      (qi,qx) <- dat(j)
    } yield (qx - px)/(qi - pi)).sorted
    val mbest = slopes(slopes.length/2)
    val mp05 = slopes(slopes.length/20)
    val mp95 = slopes(slopes.length-(1+slopes.length/20))
    val intercepts = dat.flatMap{ _.map{ case (li,lx) => lx - li*mbest } }.sorted
    val bbest = est.invfx(intercepts(intercepts.length/2))
    val fracrms = math.sqrt(data.map{ case (x,ys) => ys.map(y => sq(1 - y/est.model(Yp(x,mbest,bbest)))).sum }.sum / data.map(_._2.length).sum)
    Fit(mbest, bbest, (mp05,mp95), fracrms)
  }

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

val tl3 = new TimeLord(x=>Vector.fill(x)(util.Random.nextInt))(_.sorted)
val timings = tl3.multibench(100,10000,0.1,10,10)

// Regular n^alpha fit
scala> tl3.theilsen( timings )
res20: tl3.Fit = Fit(1.1811648421030059,3.353753446942075E-8,(1.1100382697696545,1.3204652930525234),0.05927994882343982)

// log(n)*n^alpha fit--note first value is closer to an integer
//   and last value (error) is smaller
scala> tl3.theilsen( timings, tl3.logalpha )
res21: tl3.Fit = Fit(1.0369167329732445,9.211366397621766E-9,(0.9722967182484441,1.129869067913768),0.04026308919615681)

(Правка: исправлено вычисление RMS, так что это фактически среднее значение, плюс продемонстрированочто вам нужно сделать только один раз, а затем попробовать обе попытки.)

14 голосов
/ 12 января 2012

Я не думаю, что ваш подход будет работать в целом.

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

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

Другая проблема заключается в том, что, если вы реализуете это в Java / Scala, вам придется приложить немало усилий, чтобы устранить искажения и «шум» во времени из-за таких вещей, как разогрев JVM (например, загрузка классов, компиляция JIT, изменение размера кучи) и вывоз мусора.

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


Followup

В ответ на этот комментарий:

Значимость вашей оценки резко улучшится с увеличением количества и размера выборок, которые вы используете.

Это правда, хотя я хочу сказать, что вы (Даниэль) не учли это.

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

Для простых случаев да.

Для сложных случаев и случаев из реальной жизни это сомнительное предположение. Например:

  • Предположим, что какой-то алгоритм использует хеш-таблицу с большим, но фиксированным размером основного хеш-массива и использует внешние списки для обработки коллизий. Для N (== количество записей) меньше, чем размер основного хеш-массива, поведение большинства операций будет выглядеть так: O(1). Истинное поведение O(N) может быть обнаружено только путем подбора кривой, когда N становится намного больше этого значения.

  • Предположим, что алгоритм использует много памяти или пропускную способность сети. Как правило, это будет работать хорошо, пока вы не достигнете предела ресурсов, а затем производительность будет сильно снижаться. Как вы это объясняете? Если это часть «эмпирической сложности», как вы можете быть уверены, что попали в точку перехода? Если вы хотите исключить это, как вы это делаете?

7 голосов
/ 12 января 2012

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

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

  • 1x => O (1)
  • 1.2x => O (ln ln n)
  • ~ 2-5x => O (ln n)
  • 10x => O (n)
  • 20-50x => O (n ln n)
  • 100x =>O (n ^ 2)

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

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

4 голосов
/ 13 января 2012

Недавно мы внедрили инструмент, который выполняет полуавтоматический анализ среднего времени выполнения для кода JVM. Вам даже не нужно иметь доступ к источникам. Он еще не опубликован (все еще исправляет некоторые недостатки юзабилити), но, надеюсь, скоро будет.

Он основан на моделях максимального правдоподобия выполнения программы [1]. Короче говоря, байт-код дополняется счетчиками стоимости. Затем целевой алгоритм запускается (распределяется, если хотите) на нескольких входах, распределением которых вы управляете. Агрегированные счетчики экстраполируются на функции с использованием эвристики (метод наименьших квадратов на трещине, в некотором роде). Из этого, больше науки приводит к оценке средней асимптотики во время выполнения (3.576n - 1.23log(n) + 1.7, например). Например, метод способен с высокой точностью воспроизвести строгий классический анализ, выполненный Кнутом и Седжвиком.

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

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

См. мой ответ на cs.SE , чтобы узнать немного подробнее и получить дополнительные ссылки. Предварительный веб-сайт (включая бета-версию инструмента и опубликованные документы) можно найти здесь .

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


  1. Анализ максимального правдоподобия алгоритмов и структур данных У. Лаубе и М. Е. Небель (2010). [ препринт ]
4 голосов
/ 12 января 2012

То, чего вы хотите достичь, вообще невозможно.Даже тот факт, что алгоритм когда-либо остановится, не может быть доказан в общем случае (см. Проблема останова ).И даже если он остановится на ваших данных, вы все равно не сможете определить сложность, запустив его.Например, пузырьковая сортировка имеет сложность O (n ^ 2), в то время как для уже отсортированных данных она работает так, как если бы она была O (n).Невозможно выбрать «подходящие» данные для неизвестного алгоритма, чтобы оценить его наихудший случай.

1 голос
/ 20 января 2012

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

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

Что бы я сделал, чтобы реализовать это, - подготовить список ожидаемых функций сложности g1, g2, ..., а для данных f проверить, насколько близка постоянная f / gi + gi / f для каждого i. С помощью функции наименьших квадратов это просто вычисление дисперсии этой величины для каждого i и отчет о наименьшем. Глазное яблоко отклонений в конце и вручную проверить необычно плохой посадки.

1 голос
/ 12 января 2012

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

Как насчет добавления сбрасываемых счетчиков в ваши структуры данных, а затем увеличивать их каждый раз, когда вызывается определенная подфункция? Вы могли бы сделать подсчет @elidable, чтобы они исчезли в развернутой библиотеке.

Затем для данного метода, скажем, delete(x), вы протестируете его со всеми видами автоматически сгенерированных наборов данных, попытаетесь дать им некоторый перекос и т. Д. И соберете значения. Хотя, как указывает Игорь, вы не можете проверить, что структура данных никогда не будет нарушать границу Big-O, но вы, по крайней мере, сможете утверждать, что в реальном эксперименте заданное количество лимитов никогда не будет превышено (например, при падении узла дерево никогда не делается больше, чем 4 * log(n) раз), поэтому вы можете обнаружить некоторые ошибки.

Конечно, вам потребуются определенные предположения, например, что вызов метода - это O (1) в модели вашего компьютера.

1 голос
/ 12 января 2012

Вы должны рассмотреть возможность изменения критических аспектов вашей задачи.

Измените терминологию, которую вы используете: «оценить время выполнения алгоритма» или «установить регрессионное тестирование производительности»

Можете ли вы оценить время выполнения алгоритма? Ну, вы предлагаете попробовать разные размеры входных данных и измерить какую-то критическую операцию или время, которое требуется. Затем для ряда входных размеров вы планируете программно оценить, не имеет ли время выполнения алгоритма роста, постоянного роста, экспоненциального роста и т. Д.

Таким образом, у вас есть две проблемы: запуск тестов и программная оценка скорости роста по мере увеличения входного набора. Это звучит как разумная задача.

0 голосов
/ 12 января 2012

Для эмпирического анализа сложности программы вы должны выполнить (и время) алгоритм с заданными 10, 50, 100, 500, 1000 и т. Д. Входными элементами. Затем вы можете построить график результатов и определить наиболее подходящий порядок функций из наиболее распространенных базовых типов: константа, логарифмическая, линейная, логическая, квадратичная, кубическая, полиномиальная, экспоненциальная. Это нормальная часть нагрузочного тестирования, которое гарантирует, что алгоритм сначала ведет себя как теоретически, а во-вторых, что он соответствует реальным ожиданиям производительности, несмотря на свою теоретическую сложность (алгоритм логарифмического времени, в котором каждый шаг занимает 5 минут потерять все тесты, кроме абсолютной наивысшей мощности, из-за алгоритма квадратичной сложности, в котором каждый шаг составляет несколько миллисекунд).

РЕДАКТИРОВАТЬ: разбивая его, алгоритм очень прост:

Определите список N различных значений мощности, для которых вы хотите оценить производительность (10 100 100 000 10000 и т. Д.)

Для каждого элемента X в N:

Создайте подходящий набор тестовых данных с элементами X.

Запустите секундомер или определите и сохраните текущее системное время.

Запустить алгоритм на тестовом наборе X-элементов.

Остановите секундомер или снова определите системное время.

Разница между временем начала и окончания - это время выполнения вашего алгоритма по X элементам.

Повторите для каждого X в N.

График результатов; учитывая X элементов (ось X), алгоритм занимает время T (ось Y). Ближайшая базовая функция, управляющая увеличением T при увеличении X, - это ваше приближение Биг-О. Как утверждал Рафаэль, это приближение именно таково и не даст вам очень тонких различий, таких как коэффициенты N, которые могли бы сделать различие между алгоритмом N ^ 2 и алгоритмом 2N ^ 2 (оба технически O (N ^ 2) но при одинаковом количестве элементов один будет выполнять в два раза быстрее).

...