Почему мой поиск Trie медленнее, чем у стандартных карт F#?

Итак, я только что портировал Trie из OCaml. К сожалению, он работает медленнее, чем стандартная карта с точки зрения tryFind. Я этого не понимаю - похоже, что трие должно быть быстрее. Созданы ли библиотеки кода F# каким-то особым образом, чтобы сделать их быстрее кода, который обычно развертывает пользователь?

Вот код -

[<RequireQualifiedAccess>]
module Trie

type Node<'k, 'v when 'k : comparison> =
    { TrieMap : Map<'k, Node<'k, 'v>>
      TrieKvp : ('k list * 'v) option }
    member inline x.IsEmpty = x.TrieKvp.IsNone && x.TrieMap.IsEmpty

let inline make map kvp =
    { TrieMap = map
      TrieKvp = kvp }

let inline makeEmpty () : Node<'k, 'v> = make Map.empty None

let inline isEmpty (node : Node<'k, 'v>) = node.IsEmpty

let rec tryFind (key : 'k list) node =
    if key.IsEmpty then
        match node.TrieKvp with
        | Some (_, value) -> Some value
        | None -> None
    else
        let keyHead = key.Head
        let keyTail = key.Tail
        let optSubNode = Map.tryFind keyHead node.TrieMap
        match optSubNode with
        | Some subNode -> tryFind keyTail subNode
        | None -> None

let inline containsKey key node =
    (tryFind key node).IsSome

let rec addInternal (key : 'k list) value node =
    if key.IsEmpty then make node.TrieMap (Some (key, value))
    else
        let keyHead = key.Head
        let keyTail = key.Tail
        let newTrie =
            match Map.tryFind keyHead node.TrieMap with
            | Some subTrie -> subTrie
            | None -> makeEmpty ()
        let newTrie2 = addInternal keyTail value newTrie
        make (Map.add keyHead newTrie2 node.TrieMap) node.TrieKvp

let inline add key value node =
    addInternal key value node

let rec addMany kvps node =
    if Seq.isEmpty kvps then node
    else
        let kvpHead = Seq.head kvps
        let kvpTail = Seq.skip 1 kvps
        let newTrie = add (fst kvpHead) (snd kvpHead) node
        addMany kvpTail newTrie

let inline ofList kvps =
    addMany kvps (makeEmpty ())

let inline ofListBy by kvps =
    let pairs = List.map by kvps
    ofList pairs

let rec foldInternal folder rev node state =
    match node.TrieKvp with
    | Some (_, value) -> folder (Map.fold (fun state key value -> foldInternal folder (key :: rev) value state) state node.TrieMap) (List.rev rev) value
    | None -> Map.fold (fun state key value -> foldInternal folder (key :: rev) value state) state node.TrieMap

let inline fold folder state node =
    foldInternal folder [] node state

let rec map (mapper : 'k list -> 'v -> 'a) (node : Node<'k, 'v>) : Node<'k, 'a> =
    match node.TrieKvp with
    | Some (key, value) -> make (Map.map (fun _ value -> map mapper value) node.TrieMap) (Some (key, mapper key value))
    | None -> make (Map.map (fun _ value -> map mapper value) node.TrieMap) None

let inline toValueList node =
    fold (fun state _ value -> value :: state) [] node

let inline singleton (key, value) =
    add key value (makeEmpty ())

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

let xs = Array.init 1000000 (fun i -> [i])
let timer = System.Diagnostics.Stopwatch.StartNew()
let mutable t = Trie.makeEmpty()
for i=0 to xs.Length-1 do
    t <- Trie.add xs.[i] xs.[i] t
printfn "Trie took %fs to build" timer.Elapsed.TotalSeconds
timer.Restart()
for _ in 1..100 do
    for i=0 to xs.Length-1 do
        ignore(Trie.tryFind xs.[i])
printfn "Trie took %fs to search" timer.Elapsed.TotalSeconds

let timer = System.Diagnostics.Stopwatch.StartNew()
let mutable t = Map.empty
for i=0 to xs.Length-1 do
    t <- Map.add xs.[i] xs.[i] t
printfn "Map took %fs to build" timer.Elapsed.TotalSeconds
timer.Restart()
for _ in 1..100 do
    for i=0 to xs.Length-1 do
        ignore(Map.tryFind xs.[i])
printfn "Map took %fs to search" timer.Elapsed.TotalSeconds

ПРИМЕЧАНИЕ. Если вам нужна более быстрая структура данных для поиска, обратите внимание, что мне нужна постоянная структура данных.


person Bryan Edds    schedule 13.07.2012    source источник
comment
Можете ли вы предоставить тест, показывающий, что ваш Trie медленнее, чем Map?   -  person pad    schedule 13.07.2012
comment
Если вы готовы обменять пространство на скорость поиска, вы можете хранить ключи в разреженных массивах.   -  person Daniel    schedule 13.07.2012
comment
Даниал - я считаю, что использование разреженных массивов приведет либо к сохранению, либо к производительности (в зависимости от того, как оно используется).   -  person Bryan Edds    schedule 13.07.2012
comment
@BryanEdds: Вы пробовали использовать list вместо Map?   -  person Daniel    schedule 13.07.2012
comment
Даниэль: Нет, я определенно могу это попробовать.   -  person Bryan Edds    schedule 13.07.2012
comment
Я не вижу тестов, показывающих, как вы измеряете производительность. Я чувствую, что большинство людей, которые задают вопросы о производительности, понятия не имеют, что они делают, как тестировать, как использовать различные тестовые данные, как измерять, как применять статистику. Докажите обратное, если хотите получить ответ.   -  person Brian    schedule 13.07.2012
comment
Я уже измерил и профилировал - его использование замедляет мою программу примерно в 2 раза. После создания профиля необходимо приступить к анализу. Казалось бы очевидным, что профилировщики показывают только скорость частей программы, а не то, почему каждая часть на самом деле медленная. В какой-то момент требуется априорный анализ. Это то, что я прошу здесь. Тем не менее, я работал над изолированным тестом, но сейчас я на работе, поэтому не было времени вставить его сюда. В любом случае, не стесняйтесь не помогать. Мне нечего вам доказывать, и ваши личные обиды - это ваша проблема, а не моя.   -  person Bryan Edds    schedule 13.07.2012
comment
@BryanEdds: Брайан, вероятно, исходит из того, что вы задали вопрос о производительности, которая определяется субъективно, но не указали, как вы ожидаете, что она будет измерена/улучшена. В такой форме это всего лишь игра в догадки, на которую де-факто нельзя ответить. Вы можете начать с демонстрации кода, демонстрирующего разницу.   -  person Daniel    schedule 13.07.2012
comment
Это зависит от того, как вы на это смотрите, я полагаю. К счастью, toyvo уже провел много продуктивных дискуссий (см. ниже), и как только я пойму проблему, я опубликую решение здесь. Эта ветка будет развиваться по мере того, как я создаю вещи по запросу людей. Но это требует времени, поэтому, боюсь, требует терпения. В любом случае, я не вижу веских причин для поведения Брайана. Обвинять людей в переполнении стека просто глупо.   -  person Bryan Edds    schedule 13.07.2012
comment
Он ответил на множество вопросов и, вероятно, видел много неудачников. Это может сделать человека капризным. Только представьте, что он улыбался, когда печатал это.   -  person Daniel    schedule 13.07.2012
comment
Я, наверное, ты прав. Я сам сейчас довольно капризный. Во всяком случае, я буду продолжать работать над улучшением этого вопроса, чтобы на него можно было ответить. Спасибо!   -  person Bryan Edds    schedule 13.07.2012
comment
Итак, я нашел код измерения Джона адекватным, поэтому я вставил его в вопрос. Улучшение этого измерения улучшает структуру данных в соответствии с этим вопросом.   -  person Bryan Edds    schedule 16.07.2012


Ответы (3)


К сожалению, он работает медленнее, чем стандартная карта с точки зрения tryFind. Я этого не понимаю - похоже, что трие должно быть быстрее.

Быстрый тест здесь предполагает, что ваша попытка уже быстрее, чем Map, по крайней мере, для простого случая:

do
    let n = 0
    let xs = Array.init 1000000 (fun i -> [i])
    let timer = System.Diagnostics.Stopwatch.StartNew()
    let mutable t = Trie.makeEmpty()
    for i=0 to xs.Length-1 do
        t <- Trie.add xs.[i] xs.[i] t
    printfn "Trie took %fs to build" timer.Elapsed.TotalSeconds
    timer.Restart()
    for _ in 1..100 do
        for i=0 to xs.Length-1 do
            ignore(Trie.tryFind xs.[i])
    printfn "Trie took %fs to search" timer.Elapsed.TotalSeconds

    let timer = System.Diagnostics.Stopwatch.StartNew()
    let mutable t = Map.empty
    for i=0 to xs.Length-1 do
        t <- Map.add xs.[i] xs.[i] t
    printfn "Map took %fs to build" timer.Elapsed.TotalSeconds
    timer.Restart()
    for _ in 1..100 do
        for i=0 to xs.Length-1 do
            ignore(Map.tryFind xs.[i])
    printfn "Map took %fs to search" timer.Elapsed.TotalSeconds

Я получаю 4 секунды на сборку Trie, 8,7 секунд на создание Map и примерно 0.7 на поиск в обоих случаях.

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

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

ИЗМЕНИТЬ

KVB предложил мне уточнить «возможности для улучшения», поэтому вот некоторые отзывы:

  • Используйте inline экономно в качестве оптимизации и только на основе убедительных измерений производительности.
  • Сделайте empty значением, а не функцией.
  • По возможности избегайте List.head и List.tail. Вместо этого используйте сопоставление с образцом.
  • По возможности избегайте универсальности (например, в данном случае жесткого кода для строковых ключей).
person J D    schedule 14.07.2012
comment
Вместо того, чтобы просто дать ссылку на аннотацию о статье, которая не находится в свободном доступе, возможно, вы могли бы также включить некоторые конкретные мысли о возможностях для улучшения, которые вы видите? - person kvb; 14.07.2012
comment
Я надеялся, что ты заскочишь, Джон! Мне еще предстоит получить подписку на ваш сайт, но если созданный вами оптимизированный общий хеш-имплемент также будет постоянным, я определенно зарегистрируюсь, просто чтобы увидеть его. Конечно, это на самом деле не помогает другим членам SO, поэтому не стесняйтесь уточнять здесь, если хотите :) - person Bryan Edds; 15.07.2012
comment
@BryanEdds Я с радостью подробно расскажу о возможностях для улучшения, но вы должны простить меня за то, что я держал качественный контент за платным доступом. Детей кормить и все такое. ;-) - person J D; 15.07.2012
comment
Я пытался сделать его значением, но я продолжаю получать ограничение значения - let empty = make Map.empty None. Я пытался добавить аннотацию, но безрезультатно. Вы знаете, как сделать эту компиляцию? - person Bryan Edds; 16.07.2012
comment
Пожалуйста, прекратите публиковать ссылки на свои собственные статьи, защищенные платным доступом. Ссылки абсолютно необходимы для ответа? - person ChrisF; 19.07.2012
comment
Ожидается, что ответы на Stack Overflow будут самостоятельными. Часть о возможностях для улучшения не добавляет к ответу, поскольку требует посещения внешней ссылки. Платный или бесплатный контент значения не имеет. - person JimmyPena; 19.07.2012
comment
@ChrisF Пожалуйста, прекратите публиковать ссылки на свои собственные статьи, защищенные платным доступом. Ссылки абсолютно необходимы для ответа? Ссылка на статью, которую я недавно написал именно на эту тему (оптимизация попыток в F), которая содержит больше подробностей, чем я когда-либо видел где-либо еще. - person J D; 20.07.2012
comment
@JonHarrop Это может быть так, но, поскольку он находится за платным доступом, это выглядит как самореклама, что (как вы должны знать) не приветствуется. - person ChrisF; 20.07.2012
comment
@ChrisF не приветствуется. Согласно мета QA, на который мне ссылались по этому поводу, самореклама не осуждается на SO, если вы четко указываете свою принадлежность (что я и сделал). - person J D; 20.07.2012
comment
@ChrisF Я согласен с тем, что на данный момент, вероятно, не так много процентов, но это может произойти. Истинный. Я был в ужасе, узнав, что 50% моих ответов, получивших наибольшее количество голосов, еще не имеют ссылок на наши сайты. - person J D; 20.07.2012

Итак, немного подумав, я предположил, что реальная разница в производительности заключается в использовании списков для ключей, а не строк. Строки (и массивы) имеют гораздо лучшую когерентность кеша. Итак, я изменил ключ со списка k на строку и вуаля! Производительность теперь на самом деле лучше, чем у карты в моем приложении!

Вот код -

[<RequireQualifiedAccess>]
module StringTrie

type Node<'v> =
    { TrieMap : Map<char, Node<'v>>
      TrieKvp : (string * 'v) option }
    member inline x.IsEmpty = x.TrieKvp.IsNone && x.TrieMap.IsEmpty

let inline make map kvp =
    { TrieMap = map
      TrieKvp = kvp }

let inline makeEmpty () : Node<'v> = make Map.empty None

let inline isEmpty (node : Node<'v>) = node.IsEmpty

let rec tryFindInternal (key : string) index node =
    if key.Length = index then
        match node.TrieKvp with
        | Some (_, value) -> Some value
        | None -> None
    else
        let optSubNode = Map.tryFind key.[index] node.TrieMap
        match optSubNode with
        | Some subNode -> tryFindInternal key (index + 1) subNode
        | None -> None

let inline tryFind (key : string) node =
    tryFindInternal key 0 node

let inline containsKey key node =
    (tryFind key node).IsSome

let rec addInternal (key : string) index value node =
    if key.Length = index then make node.TrieMap (Some (key, value))
    else
        let char = key.[index]
        let newTrie =
            match Map.tryFind char node.TrieMap with
            | Some subTrie -> subTrie
            | None -> makeEmpty ()
        let newTrie2 = addInternal key (index + 1) value newTrie
        make (Map.add char newTrie2 node.TrieMap) node.TrieKvp

let inline add key value node =
    addInternal key 0 value node

let rec addMany kvps node =
    if Seq.isEmpty kvps then node
    else
        let kvpHead = Seq.head kvps
        let kvpTail = Seq.skip 1 kvps
        let newTrie = add (fst kvpHead) (snd kvpHead) node
        addMany kvpTail newTrie

let inline ofList kvps =
    addMany kvps (makeEmpty ())

let inline ofListBy by kvps =
    let pairs = List.map by kvps
    ofList pairs

let rec foldInternal folder rev node state =
    match node.TrieKvp with
    | Some (_, value) -> folder (Map.fold (fun state key value -> foldInternal folder (key :: rev) value state) state node.TrieMap) (List.rev rev) value
    | None -> Map.fold (fun state key value -> foldInternal folder (key :: rev) value state) state node.TrieMap

let inline fold folder state node =
    foldInternal folder [] node state

let rec map (mapper : string -> 'v -> 'a) (node : Node<'v>) : Node<'a> =
    match node.TrieKvp with
    | Some (key, value) -> make (Map.map (fun _ value -> map mapper value) node.TrieMap) (Some (key, mapper key value))
    | None -> make (Map.map (fun _ value -> map mapper value) node.TrieMap) None

let inline toValueList node =
    fold (fun state _ value -> value :: state) [] node

let inline singleton (key, value) =
    add key value (makeEmpty ())

Я также создал версию, которая работает с массивами в целом, а также работает быстро.

[<RequireQualifiedAccess>]
module ArrayTrie

type Node<'k, 'v when 'k : comparison> =
    { TrieMap : Map<'k, Node<'k, 'v>>
      TrieKvp : ('k array * 'v) option }
    member inline x.IsEmpty = x.TrieKvp.IsNone && x.TrieMap.IsEmpty

let inline make map kvp =
    { TrieMap = map
      TrieKvp = kvp }

let inline makeEmpty () : Node<'k, 'v> = make Map.empty None

let inline isEmpty (node : Node<'k, 'v>) = node.IsEmpty

let rec tryFindInternal (key : 'k array) index node =
    if key.Length = index then
        match node.TrieKvp with
        | Some (_, value) -> Some value
        | None -> None
    else
        let optSubNode = Map.tryFind key.[index] node.TrieMap
        match optSubNode with
        | Some subNode -> tryFindInternal key (index + 1) subNode
        | None -> None

let inline tryFind (key : 'k array) node =
    tryFindInternal key 0 node

let inline containsKey key node =
    (tryFind key node).IsSome

let rec addInternal (key : 'k array) index value node =
    if key.Length = index then make node.TrieMap (Some (key, value))
    else
        let char = key.[index]
        let newTrie =
            match Map.tryFind char node.TrieMap with
            | Some subTrie -> subTrie
            | None -> makeEmpty ()
        let newTrie2 = addInternal key (index + 1) value newTrie
        make (Map.add char newTrie2 node.TrieMap) node.TrieKvp

let inline add key value node =
    addInternal key 0 value node

let rec addMany kvps node =
    if Seq.isEmpty kvps then node
    else
        let kvpHead = Seq.head kvps
        let kvpTail = Seq.skip 1 kvps
        let newTrie = add (fst kvpHead) (snd kvpHead) node
        addMany kvpTail newTrie

let inline ofList kvps =
    addMany kvps (makeEmpty ())

let inline ofListBy by kvps =
    let pairs = List.map by kvps
    ofList pairs

let rec foldInternal folder rev node state =
    match node.TrieKvp with
    | Some (_, value) -> folder (Map.fold (fun state key value -> foldInternal folder (key :: rev) value state) state node.TrieMap) (List.rev rev) value
    | None -> Map.fold (fun state key value -> foldInternal folder (key :: rev) value state) state node.TrieMap

let inline fold folder state node =
    foldInternal folder [] node state

let rec map (mapper : 'k array -> 'v -> 'a) (node : Node<'k, 'v>) : Node<'k, 'a> =
    match node.TrieKvp with
    | Some (key, value) -> make (Map.map (fun _ value -> map mapper value) node.TrieMap) (Some (key, mapper key value))
    | None -> make (Map.map (fun _ value -> map mapper value) node.TrieMap) None

let inline toValueList node =
    fold (fun state _ value -> value :: state) [] node

let inline singleton (key, value) =
    add key value (makeEmpty ())

Единственное, что осталось, что, похоже, улучшит производительность, — это получить внутренний указатель на строку и включить его, а не делать индексы снова и снова. Это кажется непростым в F#, но, по крайней мере, возможно для массивов в C#.

person Bryan Edds    schedule 15.07.2012

Почему бы и нет? Как насчет OCaml, там быстрее? Поскольку ваш Trie реализован с точки зрения Map, я ожидаю, что он будет медленнее, чем Map, по крайней мере, для некоторых входных данных. Возможно, в некоторых случаях он все еще может превзойти Map, например, когда размер очень велик.

Кроме того, если вас в первую очередь интересует производительность поиска, почему бы не заморозить Trie для использования узлов на основе Dictionary?

person t0yv0    schedule 13.07.2012
comment
Трудно представить входы, для которых его попытка будет быстрее. Дерево RB сбалансировано, что делает его O(log n). Похоже, его trie будет O(m log n), где m — длина ключа. - person Daniel; 13.07.2012
comment
@Daniel, n в этих двух случаях отличается, поскольку узлы trie содержат карты гораздо меньшего размера, чем общее количество элементов N. Я думаю, что тогда сложность дерева должна быть больше похожа на O(m log N/h), где h обозначает высоту дерева. Так что это будет быстрее на ключах короче среднего. - person t0yv0; 13.07.2012
comment
toyvo - Trie должен быть быстрее там, где я его использую, например, для строковых ключей. Стандартная карта имеет производительность O (m / 2 * log n), где m — длина строкового ключа. Это связано с тем, что он использует String.compare для каждого сравнения, а это означает, что он выполняет избыточное сравнение начал каждого ключа, который, как известно, равен (подумайте об этом). С другой стороны, у этого дерева будет O (log (m * n)) время, поскольку каждое сравнение не выполняет избыточных сравнений (в конце концов, это причина использования дерева). Поскольку карта, которую я использую внутри, представляет собой карту символов, не строк, ее использование может вводить в заблуждение. - person Bryan Edds; 13.07.2012
comment
Кроме того, я думаю, что было бы невозможно узнать, когда заморозить словарь в этом типе приложения (поиск символов в среде функционального языка). - person Bryan Edds; 13.07.2012
comment
ПРИМЕЧАНИЕ. Извините, я думаю, что мое большое описание производительности trie неверно. Тем не менее, я думаю, что это должно быть лучше, чем O (m / 2 * log n) Map... Я не знаю. Теперь я запутался ... Может быть, это примерно O (m * log 18) для моего приложения (символы имени строки обычно буквенно-цифровые). - person Bryan Edds; 13.07.2012
comment
@BryanEdds, спасибо за исправление, очень привлекательный аргумент. Вернемся к сложности: таким образом, карта выполняет 1 запуск log N сравнений, каждое из которых стоит m/2, поэтому O(m log N / 2). Напротив, trie выполняет m запусков из log n сравнений, каждое из которых стоит 1, то есть O(m log n). Для попытки высоты h у нас есть N = n ** h. Затем try выполняет O(m log N / h). Звучит лучше. - person t0yv0; 13.07.2012
comment
Я думаю, что сложность trie составляет примерно O (m * log 18) для моего приложения (символы имени строки обычно буквенно-цифровые, что дает нам 36/2 = 18). Так что, если это правда, я определенно должен быть быстрее, чем Map of string to value. - person Bryan Edds; 13.07.2012
comment
По поводу замораживания словарей - верно, если ваше приложение постоянно обновляет карту, трудно понять, когда это делать. Постоянство со словарями означает O(N) копирование при записи. С другой стороны, вы можете взорвать немного памяти и автоматически искать карту в кэше с помощью dict. Может помочь с нагрузками на производительность с преобладанием чтения. - person t0yv0; 13.07.2012