Haskell-Speichereffizienz - welches ist der bessere Ansatz?

11

Wir implementieren eine Matrixkomprimierungsbibliothek, die auf einer modifizierten zweidimensionalen Grammatiksyntax basiert. Jetzt haben wir zwei Ansätze für unsere Datentypen: Welcher ist bei Speichernutzung besser? (wir wollen etwas komprimieren;)).

Die Grammatiken enthalten NonTerminals mit genau 4 Produktionen oder ein Terminal auf der rechten Seite. Wir benötigen die Namen der Produktionen für Gleichheitsprüfungen und Grammatikminimierung.

Der Erste:

-- | Type synonym for non-terminal symbols
type NonTerminal = String

-- | Data type for the right hand side of a production
data RightHandSide = DownStep NonTerminal NonTerminal NonTerminal NonTerminal | Terminal Int

-- | Data type for a set of productions
type ProductionMap = Map NonTerminal RightHandSide

data MatrixGrammar = MatrixGrammar {
    -- the start symbol
    startSymbol :: NonTerminal,
    -- productions
    productions :: ProductionMap    
    } 

Hier speichern unsere RightHandSide-Daten nur String-Namen, um die nächsten Produktionen zu bestimmen, und wir wissen hier nicht, wie Haskell diese Strings speichert. Zum Beispiel hat die Matrix [[0, 0], [0, 0]] 2 Produktionen:

a = Terminal 0
aString = "A"
b = DownStep aString aString aString aString
bString = "B"
productions = Map.FromList [(aString, a), (bString, b)]

Die Frage hier ist also, wie oft der String "A" wirklich gespeichert wird. Einmal in aString, 4 mal in b und einmal in Produktionen oder nur einmal in aString und die anderen haben nur "billigere" Referenzen?

Der Zweite:

data Production = NonTerminal String Production Production Production Production
                | Terminal String Int 

type ProductionMap = Map String Production

hier ist der Begriff "Terminal" etwas irreführend, weil es eigentlich die Produktion ist, die ein Terminal als rechte Seite hat. Die gleiche Matrix:

a = Terminal "A" 0
b = NonTerminal "B" a a a a
productions = Map.fromList [("A", a), ("B", b)]

und die ähnliche Frage: Wie oft wird die Produktion von Haskell intern gespeichert? Möglicherweise werden wir die Namen in den Produktionen ablegen, wenn wir sie nicht brauchen, aber wir sind uns derzeit nicht sicher.

Nehmen wir also an, wir haben eine Grammatik mit ungefähr 1000 Produktionen. Welcher Ansatz verbraucht weniger Speicher?

Zum Schluss noch eine Frage zu ganzen Zahlen in Haskell: Derzeit planen wir, einen Namen als Strings zu haben. Aber wir könnten leicht zu ganzzahligen Namen wechseln, da wir bei 1000 Produktionen Namen mit mehr als 4 Zeichen haben (von denen ich annehme, dass sie 32 Bit sind?). Wie geht Haskell damit um? Weist ein Int immer 32 Bit und Integer Speicher zu, den es wirklich benötigt?

Ich habe auch Folgendes durchgelesen : Entwickeln eines Tests der Wert- / Referenzsemantik von Haskell - aber ich kann nicht herausfinden, was das genau für uns bedeutet - ich bin eher ein zwingendes Java-Kind als ein guter funktionaler Programmierer: P.

Dennis Ich
quelle

Antworten:

7

Sie können Ihre Matrixgrammatik zu einem ADT mit perfektem Teilen mit ein wenig Trick erweitern:

{-# LANGUAGE DeriveFunctor, DeriveFoldable, DeriveTraversable #-}

import Data.Map
import Data.Foldable
import Data.Functor
import Data.Traversable

-- | Type synonym for non-terminal symbols
type NonTerminal = String

-- | Data type for the right hand side of a production
data RHS a = DownStep NonTerminal NonTerminal NonTerminal NonTerminal | Terminal a
  deriving (Eq,Ord,Show,Read,Functor, Foldable, Traversable)

data G a = G NonTerminal (Map NonTerminal (RHS a))
  deriving (Eq,Ord,Show,Read,Functor)

data M a = Q (M a) (M a) (M a) (M a) | T a
  deriving (Functor, Foldable, Traversable)

tabulate :: G a -> M a
tabulate (G s pm) = loeb (expand <$> pm) ! s where
  expand (DownStep a11 a12 a21 a22) m = Q (m!a11) (m!a12) (m!a21) (m!a22)
  expand (Terminal a)               _ = T a

loeb :: Functor f => f (f b -> b) -> f b
loeb x = xs where xs = fmap ($xs) x

Hier habe ich Ihre Grammatiken verallgemeinert, um jeden Datentyp zu tabulateberücksichtigen , nicht nur Int, und werde die Grammatik nehmen und erweitern, indem ich sie mit sich selbst zusammenfalte loeb.

loebwird in einem Artikel von Dan Piponi beschrieben

Die resultierende Erweiterung als ADT benötigt physisch nicht mehr Speicher als die ursprüngliche Grammatik - tatsächlich dauert es ein gutes Stück weniger, da der zusätzliche Protokollfaktor für den Kartenrücken nicht benötigt wird und nicht gespeichert werden muss die Saiten überhaupt.

Im Gegensatz zur naiven Erweiterung kann loebich mit "den Knoten knüpfen" und die Thunks für alle Vorkommen desselben Nicht-Terminals teilen.

Wenn Sie mehr in die Theorie all dessen eintauchen möchten, können wir sehen, dass RHSdaraus ein Basisfunktor werden könnte:

data RHS t nt = Q nt nt nt nt | L t

und dann ist mein M-Typ nur der Fixpunkt davon Functor.

M a ~ Mu (RHS a)

while G awürde aus einer ausgewählten Zeichenfolge und einer Zuordnung von Zeichenfolgen zu bestehen (RHS String a).

Wir können dann erweitern Gin Mindem Sie den Eintrag in einer Karte von erweiterten Strings lazily Nachschlagen auf.

Dies ist eine Art Dual von dem, was in dem data-reifyPaket getan wird , was einen solchen Basis-Funktor und so etwas wie Mdas moralische Äquivalent von Ihnen Gdaraus wiederherstellen kann. Sie verwenden einen anderen Typ für die Nicht-Terminal-Namen, der im Grunde nur ein ist Int.

data Graph e = Graph [(Unique, e Unique)] Unique

und stellen einen Kombinator bereit

reifyGraph :: MuRef s => s -> IO (Graph (DeRef s))

Dies kann mit einer geeigneten Instanz für die oben genannten Datentypen verwendet werden, um ein Diagramm (MatrixGrammar) aus einer beliebigen Matrix zu erhalten. Es wird keine Deduplizierung identischer, aber separat gespeicherter Quadranten durchgeführt, aber die gesamte im Originaldiagramm vorhandene Freigabe wird wiederhergestellt.

Edward KMETT
quelle
8

In Haskell ist der String-Typ ein Alias ​​für [Char], eine reguläre Haskell- Liste von Char, kein Vektor oder Array. Char ist ein Typ, der ein einzelnes Unicode-Zeichen enthält. String-Literale sind, sofern Sie keine Spracherweiterung verwenden, Werte vom Typ String.

Ich denke, Sie können aus dem Obigen erraten, dass String keine sehr kompakte oder anderweitig effiziente Darstellung ist. Zu den gängigen alternativen Darstellungen für Zeichenfolgen gehören die von Data.Text und Data.ByteString bereitgestellten Typen.

Für zusätzlichen Komfort können Sie -XOverloadedStrings verwenden, sodass Sie Zeichenfolgenliterale als Darstellungen eines alternativen Zeichenfolgentyps verwenden können, wie er beispielsweise von Data.ByteString.Char8 bereitgestellt wird. Dies ist wahrscheinlich die platzsparendste Methode, um Zeichenfolgen bequem als Bezeichner zu verwenden.

Was Int betrifft, handelt es sich um einen Typ mit fester Breite, es gibt jedoch keine Garantie dafür, wie breit er ist, außer dass er breit genug sein muss, um die Werte [-2 ^ 29 .. 2 ^ 29-1] aufzunehmen. Dies deutet darauf hin, dass es mindestens 32 Bit sind, schließt jedoch 64 Bit nicht aus. Data.Int verfügt über einige spezifischere Typen, Int8-Int64, die Sie verwenden können, wenn Sie eine bestimmte Breite benötigen.

Bearbeiten, um Informationen hinzuzufügen

Ich glaube nicht, dass die Semantik von Haskell irgendetwas über die gemeinsame Nutzung von Daten aussagt. Sie sollten nicht erwarten, dass zwei String-Literale oder zwei konstruierte Daten auf dasselbe 'kanonische' Objekt im Speicher verweisen. Wenn Sie einen konstruierten Wert an einen neuen Namen binden würden (mit let, einer Musterübereinstimmung usw.), würden beide Namen höchstwahrscheinlich auf dieselben Daten verweisen, aber ob dies der Fall ist oder nicht, ist aufgrund der unveränderlichen Natur von nicht wirklich sichtbar Haskell-Daten.

Aus Gründen der Speichereffizienz können Sie die Zeichenfolgen internieren , in denen im Wesentlichen eine kanonische Darstellung der einzelnen Zeichenfolgen in einer Nachschlagetabelle gespeichert ist, normalerweise in einer Hash-Tabelle. Wenn Sie ein Objekt internieren, erhalten Sie einen Deskriptor dafür zurück, und Sie können diese Deskriptoren mit anderen vergleichen, um festzustellen, ob sie viel billiger als Zeichenfolgen sind und oft auch viel kleiner.

Für eine Bibliothek, die interniert, können Sie https://github.com/ekmett/intern/ verwenden.

Bei der Entscheidung, welche Ganzzahlgröße zur Laufzeit verwendet werden soll, ist es ziemlich einfach, Code zu schreiben, der von Integral- oder Num-Typklassen anstelle konkreter numerischer Typen abhängt. Durch Typinferenz erhalten Sie die allgemeinsten Typen, die automatisch verwendet werden können. Sie könnten dann einige verschiedene Funktionen mit Typen haben, die explizit auf bestimmte numerische Typen eingegrenzt sind, von denen Sie zur Laufzeit eine auswählen können, um die Ersteinrichtung durchzuführen, und danach würden alle anderen polymorphen Funktionen bei allen gleich funktionieren. Z.B:

polyConstructor :: Integral a => a -> MyType a
int16Constructor :: Int16 -> MyType Int16
int32Constructor :: Int32 -> MyType Int32

int16Constructor = polyConstructor
int32Constructor = polyConstructor

Bearbeiten : Weitere Informationen zum Praktikum

Wenn Sie nur Zeichenfolgen internieren möchten, können Sie einen neuen Typ erstellen, der eine Zeichenfolge (vorzugsweise einen Text oder ByteString) und eine kleine Ganzzahl zusammenhält.

data InternedString = { id :: Int32, str :: Text }
instance Eq InternedString where
    {x, _ } == {y, _ }  =  x == y

intern :: MonadIO m => Text -> m InternedString

'Intern' sucht die Zeichenfolge in einer HashMap mit schwacher Referenz, in der Texte Schlüssel und InternedStrings Werte sind. Wenn eine Übereinstimmung gefunden wird, gibt 'intern' den Wert zurück. Wenn nicht, wird ein neuer InternedString-Wert mit dem ursprünglichen Text und einer eindeutigen Ganzzahl-ID erstellt (weshalb ich die MonadIO-Einschränkung eingefügt habe. Stattdessen kann eine Status-Monade oder eine unsichere Operation verwendet werden, um die eindeutige ID abzurufen. Es gibt viele Möglichkeiten.) und speichert es in der Karte, bevor Sie es zurückgeben.

Jetzt erhalten Sie einen schnellen Vergleich basierend auf der Ganzzahl-ID und haben nur eine Kopie jeder eindeutigen Zeichenfolge.

Die interne Bibliothek von Edward Kmett wendet das gleiche Prinzip mehr oder weniger viel allgemeiner an, sodass ganze strukturierte Datenbegriffe gehasht, eindeutig gespeichert und einer schnellen Vergleichsoperation unterzogen werden. Es ist ein bisschen entmutigend und nicht besonders dokumentiert, aber er könnte bereit sein zu helfen, wenn Sie fragen; Oder Sie können einfach zuerst Ihre eigene String-Interning-Implementierung ausprobieren, um zu sehen, ob sie ausreicht.

Levi Pearson
quelle
Vielen Dank für Ihre bisherige Antwort. Ist es möglich zu bestimmen, welche int-Größe wir zur Laufzeit verwenden sollen? Ich hoffe, jemand anderes kann etwas über das Problem mit den Kopien sagen :)
Dennis Ich
Vielen Dank für die zusätzlichen Informationen. Ich werde dort einen Blick darauf werfen. Nur um es richtig zu machen, sind diese Deskriptoren, von denen Sie sprechen, so etwas wie eine Referenz, die gehasht wird und verglichen werden kann? Hast du selbst damit gearbeitet? Kannst du vielleicht sagen, wie "komplizierter" es damit wird, denn auf den ersten Blick scheint es, als müsste ich sehr vorsichtig sein, wenn ich die Grammatiken definiere;)
Dennis Ich
1
Der Autor dieser Bibliothek ist ein sehr fortgeschrittener Haskell-Benutzer, der für hochwertige Arbeit bekannt ist, aber ich habe diese bestimmte Bibliothek nicht verwendet. Es handelt sich um eine sehr allgemeine "Hash-Cons" -Implementierung, die die gemeinsame Nutzung von Darstellungen in jedem erstellten Datentyp und nicht nur in Zeichenfolgen speichert und ermöglicht . Schauen Sie in seinem Beispielverzeichnis nach einem Problem wie Ihrem, und Sie können sehen, wie die Gleichheitsfunktionen implementiert sind.
Levi Pearson