Выполнение последовательностей с помощью 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
.
В зависимости от ограничений и ввода, решение, отличное от лени, может быть хорошим выбором. Я предложил несколько вариантов здесь. Как всегда, вам нужно будет протестировать в своей реальной среде.