Lazy Partition-by

8

Ich habe eine Quelle von Elementen und möchte Läufe von ihnen mit dem gleichen Wert einer Schlüsselfunktion getrennt verarbeiten. In Python würde das wie

aussehen %Vor%

Diese Lösung ist völlig faul, d. h. wenn process nicht versucht, den gesamten Inhalt von part zu speichern, würde der Code in O(1) memory ausgeführt werden.

Clojure Lösung

%Vor%

ist weniger faul: es realisiert jeden Teil vollständig. Das Problem ist, dass src sehr lange Läufe von Elementen mit demselben key-fn -Wert haben könnte und dass die Realisierung zu OOM führen könnte.

Ich habe diese Diskussion gefunden, in der behauptet wird, dass die folgende Funktion (geringfügig geändert für die Benennung Konsistenz innerhalb Post) ist faul genug

%Vor%

Allerdings verstehe ich nicht, warum es nicht unter OOM leidet: beide Teile der Cons-Zelle enthalten einen Verweis auf s , also während process konsumiert part , s wird aber realisiert nicht Müll gesammelt. Es würde nur dann für GC infrage kommen, wenn drop-while part durchläuft.

Meine Fragen sind also:

  1. Habe ich Recht, dass lazy-partition-by nicht faul genug ist?
  2. Gibt es eine Implementierung von partition-by mit garantierten Speicheranforderungen, vorausgesetzt, dass ich keine Referenzen auf die vorherige part halte, bis ich anfange, die nächste zu realisieren?

BEARBEITEN: Hier ist eine faule genug Implementierung in Haskell:

%Vor%

Wie aus span implementation , part und rest teilen sich den Status implizit. Ich frage mich, ob diese Methode in Clojure übersetzt werden könnte.

    
tempestadept 14.07.2014, 13:58
quelle

3 Antworten

3

Obwohl diese Frage eine sehr interessante Betrachtung des Sprachdesigns hervorruft, ist das praktische Problem, dass Sie Partitionen in konstantem Speicher verarbeiten möchten. Und das praktische Problem ist mit etwas Inversion lösbar.

Übergeben Sie die Verarbeitungsfunktion nicht an das Ergebnis einer Funktion, die eine Folge von Partitionen zurückgibt, sondern an die Funktion, die die Partitionen erzeugt. Dann können Sie den Status in einer geschlossenen Weise steuern.

Zuerst werden wir einen Weg finden, den Verbrauch der Sequenz mit dem Zustand des Schwanzes zu verschmelzen.

%Vor%

Dann eine modifizierte Version von partition-by

%Vor%

Hinweis: Für O (1) Speicherverbrauch processfn muss ein eifriger Konsument sein! Also während (process-partition-by identity key-fn coll) dasselbe ist wie (partition-by key-fn coll) , weil identity die Partition nicht belegt , der Speicherverbrauch ist nicht konstant.

Tests

%Vor%     
A. Webb 16.07.2014, 19:32
quelle
7

Die Faustregel, die ich in diesen Szenarien verwende (dh die, in denen eine einzelne Eingabesequenz mehrere Ausgabesequenzen erzeugen soll), besteht darin, dass Sie von den folgenden drei wünschenswerten Eigenschaften im Allgemeinen nur zwei haben können:

  1. Effizienz (durchlaufen Sie die Eingabefolge nur einmal, halten Sie also nicht den Kopf)
  2. Faulheit (produzieren Sie Elemente nur auf Anfrage)
  3. Kein gemeinsamer änderbarer Status

Die Version in clojure.core wählt (1,3), gibt aber auf (2) auf, indem sie eine ganze Partition auf einmal erzeugt. Python und Haskell wählen beide (1,2), obwohl es nicht sofort offensichtlich ist: Hat Haskell überhaupt keinen veränderlichen Zustand? Nun, seine faule Bewertung von allem (nicht nur Sequenzen) bedeutet, dass alle Ausdrücke Thunks sind, die als leere Schiefertafeln beginnen und nur dann geschrieben werden, wenn ihr Wert benötigt wird; Die Implementierung von span , wie Sie sagen, teilt den gleichen Thunk von span p xs' in seinen beiden Ausgabesequenzen, so dass das, was immer es benötigt, es zuerst an das Ergebnis der anderen Sequenz "sendet" und die Aktion bei a ausführt Entfernung, die notwendig ist, um die anderen schönen Eigenschaften zu erhalten.

Die alternative Clojure-Implementierung, mit der Sie verbunden sind, wählt (2,3), wie Sie festgestellt haben.

Das Problem ist, dass für partition-by das Ablehnen von entweder (1) oder (2) bedeutet, dass Sie den Kopf einer Sequenz halten: entweder die Eingabe oder eine der Ausgaben. Wenn Sie also eine Lösung wünschen, bei der beliebig große Partitionen einer beliebig großen Eingabe bearbeitet werden können, müssen Sie (1,2) wählen. Es gibt ein paar Möglichkeiten, wie Sie dies in Clojure tun können:

  1. Nimm den Python-Ansatz: Gebe etwas mehr wie einen Iterator zurück als einen Seq - seqs mache stärkere Garantien über Nicht-Mutation und verspreche, dass du sie mehrere Male sicher durchqueren kannst usw. Wenn du statt einer Seq von Seqs zurückkehrst ein Iterator-Iterator, der dann Elemente von einem Iterator konsumiert, kann die anderen (s) frei mutieren oder ungültig machen. Dies garantiert, dass der Konsum in der richtigen Reihenfolge abläuft und der Speicher frei wird.
  2. Nehmen Sie den Haskell-Ansatz: Takten Sie alles manuell mit vielen Aufrufen von delay , und verlangen Sie, dass der Client so oft wie nötig force aufruft, um Daten abzurufen. Das wird in Clojure sehr viel hässlicher sein und die Stack-Tiefe stark erhöhen (dies wird bei einer nicht-trivialen Eingabe wahrscheinlich den Stack sprengen), aber es ist theoretisch möglich.
  3. Schreiben Sie etwas mehr Clojure-aromatisiert (aber immer noch ziemlich ungewöhnlich), indem Sie ein paar veränderbare Datenobjekte haben, die zwischen den Ausgabesektionen koordiniert sind und jeweils aktualisiert werden, wenn etwas von ihnen angefordert wird.

Ich bin mir ziemlich sicher, dass jeder dieser drei Ansätze möglich ist, aber um ehrlich zu sein, sind sie alle ziemlich hart und überhaupt nicht natürlich. Die Sequenzabstraktion von Clojure macht es einfach nicht einfach, eine Datenstruktur zu erzeugen, die Sie möchten. Mein Rat ist, wenn Sie so etwas brauchen und die Partitionen zu groß sind, um bequem zu passen, akzeptieren Sie einfach ein etwas anderes Format und machen ein wenig mehr Buchhaltung selbst: weichen Sie dem (1,2,3) -Dilemma aus, indem Sie nicht mehrere erzeugen Ausgabesequenzen überhaupt!

Anstatt ((2 4 6 8) (1 3 5) (10 12) (7)) als Ausgabeformat für etwas wie (partition-by even? [2 4 6 8 1 3 5 10 12 7]) zu verwenden, könnten Sie ein etwas häßlicheres Format akzeptieren: ([::key true] 2 4 6 8 [::key false] 1 3 5 [::key true] 10 12 [::key false] 7) . Dies ist weder schwer zu produzieren noch schwer zu konsumieren, obwohl es ein bisschen langwierig und mühsam ist, zu schreiben.

Hier ist eine sinnvolle Implementierung der produzierenden Funktion:

%Vor%

Und hier ist, wie Sie es konsumieren: Zuerst definieren Sie ein generisches reduce-grouped , das die Details des Gruppierungsformats verbirgt, und dann eine Beispielfunktion count-partition-sizes , um den Schlüssel und die Größe jeder Partition auszugeben, ohne irgendwelche Sequenzen im Speicher zu behalten:

%Vor%

Bearbeiten : Wenn ich es noch einmal betrachte, bin ich nicht wirklich davon überzeugt, dass mein reduce-grouped viel nützlicher ist als (reduce f init (map g xs)) , da es keinen wirklichen Hinweis darauf gibt, wann Schlüsseländerungen. Wenn Sie also wissen möchten, wann sich eine Gruppe ändert, benötigen Sie eine intelligentere Abstraktion, oder Sie verwenden meine ursprüngliche lazy-partition-by mit nichts "cleverem" Umbruch.

    
amalloy 14.07.2014 20:40
quelle
1
  

Habe ich Recht, wenn ich auf "lazy-partition" trete - indem ich nicht faul genug bin?

Nun, es gibt einen Unterschied zwischen Faulheit und Gedächtnisnutzung. Eine Sequenz kann träge sein und dennoch viel Speicher benötigen - siehe zum Beispiel die Implementierung von clojure.core/distinct , die eine Menge verwendet, um sich an alle zuvor beobachteten Werte in der Sequenz zu erinnern. Aber ja, Ihre Analyse der Speicheranforderungen von lazy-partition-by ist korrekt - der Funktionsaufruf zur Berechnung des Kopfes der zweiten Partition behält den Kopf der ersten Partition, was bedeutet, dass die Realisierung der ersten Partition dazu führt, dass sie beibehalten wird. Erinnerung. Dies kann mit dem folgenden Code überprüft werden:

%Vor%

Da weder doseq noch dorun den Kopf behalten, würde dies einfach für immer laufen, wenn lazy-partition-by O (1) im Speicher wäre.

  

Gibt es eine Implementierung von partition-by mit garantierten Speicheranforderungen, vorausgesetzt, ich halte keine Referenzen auf den vorherigen Teil, bis ich anfange, den nächsten zu realisieren?

Es wäre sehr schwierig, wenn nicht unmöglich, eine solche Implementierung rein funktional zu schreiben, was für den allgemeinen Fall funktionieren würde. Beachten Sie, dass eine allgemeine lazy-partition-by - Implementierung keine Annahmen darüber machen kann, wann (oder falls) eine Partition realisiert wird. Die einzige garantiert korrekte Art, den Anfang der zweiten Partition zu finden, besteht darin, sich nicht zu merken, wie viel von der ersten Partition realisiert wurde, um sich daran zu erinnern, wo die erste Partition begann, und vorwärts zu scannen, wenn sie angefordert wird.

Für den speziellen Fall, in dem Sie Datensätze nacheinander für Nebenwirkungen bearbeiten und sie nach Schlüssel gruppiert haben möchten (wie es bei Ihrer Verwendung von doseq oben impliziert ist), könnten Sie etwas in der Art eines Prozentsatzes berücksichtigen. co_de% / loop , das einen Zustand beibehält und bei Änderung des Schlüssels wieder setzt.

    
Alex 14.07.2014 17:03
quelle