Выполнение последовательностей с помощью while и for-do, по сравнению с прямой реализацией `IEnumerable <T>`

(извините за длинный пост, чтобы перейти непосредственно к вопросу (-ам), см. внизу)
(ОБНОВЛЕНИЕ: если вы пересматриваете, см. Разделы с надписью "update";)

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

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

let stocp0(value: string) : seq<int> =
    let pos = ref 0
    seq {
        while !position < value.Length do
            let c = System.Char.ConvertToUtf32(value, !pos)
            pos := !position + if c >= 0x10000 then 2 else 1
            yield c
    }

Попытка 1: for-do

Я понял, что проще всего было превратить его в цикл for-do (не для цикла in-do-do, у них слишком много дополнительных накладных расходов):

let inline stocp1(value: string) =
    seq {
        for i = 0 to value.Length - 1 do
            if(not <| Char.IsLowSurrogate(value.[i])) 
            then yield Char.ConvertToUtf32(value, i)
    }

Это выполнялось в 3,2 раза медленнее, чем измененный аналог выше. Более высокий фактор, чем я себе представлял.

Попытка 2: Seq.mapi

Так как строка уже является последовательностью (ок, там IEnumerable<char> обертка), я думал использовать ее с существующими функциями последовательности из модуля Seq, надеясь, что это может повысить производительность:

let inline stocp2(value: string) =
    value
        |> Seq.mapi (fun i c -> 
            if(Char.IsLowSurrogate(c)) then 0
            else Char.ConvertToUtf32(value, i))
        |> Seq.filter ((<>) 0)

Он выполнялся в 3,5 раза медленнее.

Странно, если я заменил value на value.AsEnumerable(), он выполняет значительно быстрее, чем stocp1: коэффициент 3.0.

После нескольких тестов мне стало ясно, что каждый |> создает новый слой IEnumerable<T> со всеми задействованными операциями цепочки (это также можно наблюдать в исходном коде FSharp Seq). Но размер накладных расходов удивил меня. Поскольку ни одно из вышеперечисленных действий не давало даже отдаленной производительности оригинала, я решил попытаться предотвратить лишние накладные расходы для ввода и создать функцию Seq.mapiAndFilter для выполнения обоих действий сразу.

Попытка 3: Seq.mapiAndFilter

Так как это такой тонкий цикл, и мне нужно только фильтровать текущий символ и возвращать его на основе текущей позиции, возможно, я смогу удалить дополнительный шаг, связанный с Seq.mapi, который кажется дорогим.

Для этого мне нужно было подражать поведению существующих функций Seq.xxx, и моя первая попытка заключалась в том, чтобы сделать это с циклом while-yield. Это будет ближе всего к исходному изменяемому подходу, но добавляет один слой служебных данных IEnumerable<T>.

Я написал следующую функцию, которая принимает функцию, которая возвращает логическое значение, и если true, она применяет вторую функцию в позиции текущего элемента.

let inline mapiAndFilter f g (e: seq<_>) : seq<_> =
    let position = ref -1
    let e = e.GetEnumerator()
    seq {
        while e.MoveNext() do
            position := !position + 1
            if f e.Current then yield g !position
    }


// and the application of it:
let inline stocp3(value: string) =
    value.AsEnumerable()
        |> mapiAndFilter (not << Char.IsLowSurrogate) (fun i -> Char.ConvertToUtf32 (value, i))

Результат был намного лучше, чем предыдущие попытки, и он в 1,5 раза превышал производительность изменчивого решения. Тем не менее, все еще неутешительно медленно, однако, казалось, подразумевается, что добавленные накладные расходы с счетчиками составляют около 50% в жестких циклах.

Попытка 4: улучшена Seq.mapiAndFilter

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

Без охранников функции FSharp Seq используются внутри (чтобы поднять ошибку при незаконном использовании Current и т.д.), я придумал следующее:

let mapiAndFilter f g (e : seq<'a>) : seq<'b> =
    let i = ref -1
    let e = e.GetEnumerator()
    let inline getEnum() = {
            new IEnumerator<'b> with 
                member x.Current = g !i
            interface System.Collections.IEnumerator with 
                member x.Current = box <| g !i
                member x.MoveNext() = 
                    let rec next() = 
                        i := !i + 1
                        e.MoveNext() && (f e.Current || next())
                    next()
                member x.Reset() = noReset()
            interface System.IDisposable with 
                member x.Dispose() = e.Dispose()  
        }
    {
    new System.Collections.Generic.IEnumerable<'b> with
        member x.GetEnumerator() = getEnum()
    interface System.Collections.IEnumerable with
        member x.GetEnumerator() = getEnum() :> System.Collections.IEnumerator
    }

// apply the same way as before:
let inline stocp4(value: string) =
    value.AsEnumerable()
        |> mapiAndFilter (not << Char.IsLowSurrogate) (fun i -> Char.ConvertToUtf32 (value, i))

Это стало нашим нынешним победителем! Казалось, что часы в 1,1 раза медленнее, чем исходная изменяемая функция. Конечно, он использует изменчивое состояние, но все же все функции Seq.xxx все равно.

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

Общее примечание о всех вышеперечисленных попытках: я также тестировал с помощью ToCharArray(), что улучшает производительность при входе от малого до среднего, но становится вредным для больших входных строк, особенно. когда не все элементы должны быть перечислены. Многие другие подходы я не учитывал, потому что их производительность была намного хуже (Seq.choose over Seq.filter намного медленнее, Seq.collect, очень медленно и т.д.).

Я использовал для сравнения производительности (по-видимому, Seq.length - самый быстрый способ принудительного итерации, Seq.last и Seq.iter намного медленнее):

let input = "ab\U0001ABCDcde\U0001ABCEfghi\U0001ABCF"
let run f = for i in 1 .. 1000000 do f input |> Seq.length |> ignore;;
run stocp1
// etc

Результаты:

Function  CPU     Factor
stocp0    0.296   1.0
stocp1    0.951   3.2
stocp2    1.029   3.5
stocp2'   0.873   3.0
stocp3    0.436   1.5
stocp4    0.327   1.1
stocp5    0.405   1.3 (latkin answer, adj. with Array.toSeq)

stocp' - это версия, которая использует AsEnumerable() в строке до передачи ее в функции Seq.xxx. Все остальные функции уже используют это.

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

Update:Я добавил ответ latkin как stocp5, но ему пришлось настроить, добавив к нему Array.toSeq. Без него он работает на 0.234, который быстрее, чем исходный while-loop. К сожалению, мне нужна последовательность (мы должны использовать ленивую загрузку и не можем хранить целые строки в памяти).

(обновление) Сравнение производительности, включая доступ к элементу

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

let runmap f = for i in 1 .. 1000000 do f input |> Seq.map id |> Seq.length |> ignore;;

Результаты:

Function  CPU     Factor
stocp0    0.795   1.0
stocp1    1.528   1.9
stocp2    1.731   2.2
stocp2'   1.812   2.3
stocp3    0.936   1.2
stocp4    0.842   1.1
stocp5    0.873   1.1  (credit: latkin, see his answer and notes above)

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

Поскольку наши типичные прецеденты не требуют полной итерации, я добавил тест, который повторяется только до второй пары суррогатов в позиции 6 с более крупным размером (3932160 символов).

let runmapnth f = for i in 1 .. 1000000 do f input |> Seq.map id |> Seq.nth 6 |> ignore;;

Результаты:

Function  CPU     Factor
stocp0    0.624   1.0
stocp1    1.029   1.6
stocp2    1.263   2.0
stocp2'   1.107   1.8
stocp3    0.717   1.1
stocp4    0.624   1.0
stocp5    ---     --- OOM

OutOfMemoryException с латынским ответом меня немного удивил, это означает, что созданные массивы не были очищены при использовании в замкнутом цикле, как указано выше. Моя машина выделила 8 ГБ несколько раз за несколько секунд, и капель (GC'ed?) Между ними, но в итоге все еще не удается. Странно:

8GB allocation spikes

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

Заключение, вопросы

С последним упражнением, описанным выше, я узнал, чего я не ожидал: компилятор F # только вызывает не-общий IEnumerator.Current и никогда не вызывает IEnumerator<T>.Current. Это может частично объяснить, почему ухудшение производительности с помощью цепочечных фильтров настолько заметно, когда объект, на котором вы его выполняете, является типом значения: бокс помещает его в кучу и обратно, что ужасно.

  • Почему компилятор не использует общий интерфейс?

  • Как получилось, что for-loop настолько медленный, что здесь происходит внутри? Разве он не должен превращаться в хвост-вызов, который затем скомпилирован в быстрый цикл внутри?

  • Есть ли более естественный или другой способ записи фильтра, как я сделал (mapi, затем фильтр), который не имеет недостатков пагубной производительности, которую я описал?

  • Почему существует такая большая разница между прямой (медленной) и string.AsEnumerable() (быстрой) строкой строки?

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

UPDATE:, как указано в комментариях, кажется, что бокс появляется только при запуске из FSharp Interactive (FSI). Если вы возьмете stocp4 и измените код вызова, добавив избыточное Seq.filter ((<>) 0) (или что-то подобное), он вместо этого вызовет unboxed accessor. Зачем? Не знаю.

Ответы

Ответ 1

Хорошо, я сделаю снимок. Все результаты кода и тестов можно найти здесь.

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

// ~50% faster for given test case
// still ~20% faster even for length 1.5M string
let eager1 (value: string) =
    let result = ResizeArray(value.Length)
    for i in 0 .. value.Length - 1 do
        if not (Char.IsLowSurrogate(value.[i]))
        then result.Add(Char.ConvertToUtf32(value, i))
    result.ToArray()

Generic v Non. В тестовой функции вызывается общий код.

Добавьте оператор регистрации для обоих .Current impls и передайте свою выходную последовательность на |> Seq.iter (printfn "%d"), и вы увидите ее генерируемую.

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

Циклы в seq {} Петли внутри seq { }, а другие выражения вычислений не являются регулярными циклами. (на самом деле почти ничего "нормального" взгляда внутри вычисляющих выражений на самом деле нормально, что является видом точки:)) Как указано в выражении вычисления docs, цикл for заканчивается кодированием как итерация по другому перечислимому. Циклы while немного проще.

Это более или менее объясняет, почему ваша "попытка 1" намного медленнее - цикл for приводит к распределению и повторению еще одного seq внутри вашего seq.

Трубопровод через API-интерфейсы Seq. Да, на каждом шаге это создаст новые сегменты. Если "реальная работа" задействована очень маленькая, как в этом примере, тогда накладные расходы начинают доминировать.

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

.AsEnumerable() Это довольно странно, я могу воспроизвести значительное ускорение, которое вы видите. Очень странно, учитывая, что метод расширения AsEnumerable ничего не делает, но возвращает свой вход напрямую!

Структура сгенерированного кода в этом случае очень различна. Возможно, это как-то патологический случай в оптимизаторе. Интересная находка.

Вариации. Я обнаружил, что результаты значительно меняются при включении/отключении оптимизаций и при выборе x64 vs x86. Возьмите это за то, что это стоит.


Обновить после изменения контрольных показателей и требований OP

Array.toSeq Здесь нет необходимости использовать Array.toSeq и предсказуемо перетащить производительность моего предлагаемого решения. Array.toSeq и Seq.ofArray больше для безопасности (убедитесь, что полученный seq не может быть преобразован обратно в массив потребителем и мутирован), чем преобразование типа.

Лучший выбор:

  • Просто верните массив seq<_> при его возврате
  • Обновите свои другие API, чтобы принять гибкий тип #seq<'t>, тогда даже простой массив в порядке

Обновленные требования С учетом вновь выявленных ограничений:

  • Обработка строк настолько велика, что даже 1 или 2 копии вызовут OOM
  • Частая ранняя выручка при обработке

тогда ясно, что ленивый подход будет более уместным, по крайней мере в некоторых случаях.

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

См. мой gist, приведенный выше для получения результатов. Он включает в себя альтернативные нелазные реализации:

let eager2 (value: string) =
    let result = ResizeArray(value.Length)
    for i in 0 .. value.Length - 1 do
        if not (Char.IsLowSurrogate(value.[i]))
        then result.Add(Char.ConvertToUtf32(value, i))
    // cast result so that return type isn't array
    (result.ToArray()) :> seq<_>

let eager3 (value: string) =
    let result = ResizeArray(value.Length)
    for i in 0 .. value.Length - 1 do
        if not (Char.IsLowSurrogate(value.[i]))
        then result.Add(Char.ConvertToUtf32(value, i))
    // ToArray() causes another copy to be generated.
    // Avoiding that is a win in large-input scenarios, but at a cost
    // of otherwise slower processing
    (result) :> seq<_>

Улучшение ленивого решения

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

В большинстве случаев этот парень, по-видимому, избивает не-ленивые решения!

let lazy5 (value : string) =         
    let inline getEnum() = 
        let i = ref -1
        { new IEnumerator<int> with
              member __.Current = Char.ConvertToUtf32(value, !i)
          interface System.Collections.IEnumerator with
              member __.Current =  box (Char.ConvertToUtf32(value, !i))
              member __.MoveNext() = 
                      incr i
                      if !i >= value.Length then false else
                      if not (Char.IsLowSurrogate(value.[!i])) then true else
                      incr i
                      !i < value.Length                  
              member __.Reset() = failwith "reset"
          interface IDisposable with
              member __.Dispose() = () }
    { new IEnumerable<int> with
          member __.GetEnumerator() = getEnum()
      interface IEnumerable with
          member __.GetEnumerator() = getEnum() :> IEnumerator }

Резюме

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

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