Google App Engine – Datastore Container – Performance Serialisierung mit pickle versus marshal

Da die Google App Engine pro Tag nur 50k Operationen auf den Datastore spendiert, kann man als Workaround einen Container mit mehreren Datensätzen im Datastore abspeichern. Ist z.B. ein Datensatz 500 Byte groß, dann können in einem Datastore-Container bis zu 2.000 Datensätze gespeichert werden.

Als Container eignet sich eine Hash-Liste, die als Key die ID oder den NAME des Datensatzes enthält. Die Liste wird dann serialisiert in einem BlobProperty gespeichert und der Spacebedarf kann zusätzlich mittels ZIP-Kompression reduziert werden.

Frage: Was ist schneller Marshal oder Pickle?
Antwort: Marshal ist 10 mal schneller als Pickle. 

Beim der Realisierung des Datastore-Container musste ich unbedingt die Vorgaben von Google beachten, dass ein Skript nicht langsamer als eine Sekunden sein darf. App Engines, bei denen Skripte länger als 1.000 ms laufen werden von Google in eine spezielle Sandbox ausgesondert.

Meine Implementierung vom Datastore-Container basiert auf Python. In dieser Sprache gibt es mehrere Möglichkeiten der Serialisierung und Deserialisierung eines Objektes, um dieses in einer Datei oder als Blob in der Datenbank zu speichern. Zunächst habe ich auf Pickle gesetzt, aber die Performance war in Python 2.5.4 sehr schlecht – 250 ms je Operation (Get, Put, Delete) bei einer Hash-Liste mit 300 Zeilen und 100 KB Space.

Die genauere Untersuchung der Operationen ergab: jeweils nur ca. 16 ms für das Lesen und Schreiben des Datastore-Containers, aber 133 ms für die Deserialisierung und 93 ms für die Serialisierung der Hash-Liste. Deshalb habe ich Marshal und andere Verfahren als Alternative zu Pickle untersucht.

Performancevergleich Marshal & Co versus Pickle auf Google App Engine:

  • Marshal: 187 ms / 670.091 Byte
  • CSV: 373 ms / 428.955 Byte
  • Pickle: 2.671 ms / 627.901 Byte
  • Pickle(2): 2.956 ms / 419.085 Byte
  • SimpleJSON: 7.377 ms / 938.985 Byte

Umgebung: Produktion – GAE (ca. 600 MHz CPU)

performance_test: 1000 rows, 2 rounds
test[ array ] avg: 3.3 ms / sum: 0.007 s
test[ pickle ] avg: 140.1 ms / sum: 0.28 s
test[ unpickle ] avg: 137.1 ms / sum: 0.274 s
test[ pickle_decode_encode ] avg: 277.2 ms / sum: 0.554 sec / len: 60895 byte
test[ pickle_2 ] avg: 170.5 ms / sum: 0.341 s
test[ unpickle_2 ] avg: 106.2 ms / sum: 0.212 s
test[ pickle_decode_encode_2 ] avg: 276.8 ms / sum: 0.554 sec / len: 41067 byte
test[ marshal ] avg: 1.1 ms / sum: 0.002 s
test[ unmarshal ] avg: 1.3 ms / sum: 0.003 s
test[ marshal_decode_encode ] avg: 2.4 ms / sum: 0.005 sec / len: 67091 byte
test[ simplejson ] avg: 183.3 ms / sum: 0.367 s
test[ unsimplejson ] avg: 553.5 ms / sum: 1.107 s
test[ simplejson_decode_encode ] avg: 736.9 ms / sum: 1.474 sec / len: 92984 byte
test[ csv ] avg: 6.8 ms / sum: 0.014 s
test[ uncsv ] avg: 4.4 ms / sum: 0.009 s
test[ csv_decode_encode ] avg: 11.2 ms / sum: 0.022 sec / len: 41954 byte

performance_test: 10000 rows, 2 rounds
test[ array ] avg: 129.9 ms / sum: 0.26 s
test[ pickle ] avg: 1561.7 ms / sum: 3.123 s
test[ unpickle ] avg: 1109.8 ms / sum: 2.22 s
test[ pickle_decode_encode ] avg: 2671.5 ms / sum: 5.343 sec / len: 627901 byte
test[ pickle_2 ] avg: 2012.7 ms / sum: 4.025 s
test[ unpickle_2 ] avg: 944.0 ms / sum: 1.888 s
test[ pickle_decode_encode_2 ] avg: 2956.7 ms / sum: 5.913 sec / len: 419085 byte
test[ marshal ] avg: 14.0 ms / sum: 0.028 s
test[ unmarshal ] avg: 173.3 ms / sum: 0.347 s
test[ marshal_decode_encode ] avg: 187.4 ms / sum: 0.375 sec / len: 670091 byte
test[ simplejson ] avg: 2263.3 ms / sum: 4.527 s
test[ unsimplejson ] avg: 5114.4 ms / sum: 10.229 s
test[ simplejson_decode_encode ] avg: 7377.7 ms / sum: 14.755 sec / len: 938985 byte
test[ csv ] avg: 254.2 ms / sum: 0.508 s
test[ uncsv ] avg: 118.8 ms / sum: 0.238 s
test[ csv_decode_encode ] avg: 373.0 ms / sum: 0.746 sec / len: 428955 byte

Umgebung: Entwicklung – SDK (800 MHz CPU)

performance_test: 1000 rows, 2 rounds
test[ array ] avg: 7.5 ms / sum: 0.015 s
test[ pickle ] avg: 219.0 ms / sum: 0.438 s
test[ unpickle ] avg: 187.5 ms / sum: 0.375 s
test[ pickle_decode_encode ] avg: 406.5 ms / sum: 0.813 sec / len: 60895 byte
test[ pickle_2 ] avg: 250.0 ms / sum: 0.5 s
test[ unpickle_2 ] avg: 125.0 ms / sum: 0.25 s
test[ pickle_decode_encode_2 ] avg: 375.0 ms / sum: 0.75 sec / len: 41067 byte
test[ marshal ] avg: 0.0 ms / sum: 0.0 s
test[ unmarshal ] avg: 62.5 ms / sum: 0.125 s
test[ marshal_decode_encode ] avg: 62.5 ms / sum: 0.125 sec / len: 67091 byte
test[ simplejson ] avg: 343.5 ms / sum: 0.687 s
test[ unsimplejson ] avg: 758.0 ms / sum: 1.516 s
test[ simplejson_decode_encode ] avg: 1101.5 ms / sum: 2.203 sec / len: 92984 byte
test[ csv ] avg: 39.0 ms / sum: 0.078 s
test[ uncsv ] avg: 15.5 ms / sum: 0.031 s
test[ csv_decode_encode ] avg: 54.5 ms / sum: 0.109 sec / len: 41954 byte

performance_test: 10000 rows, 2 rounds
test[ array ] avg: 140.5 ms / sum: 0.281 s
test[ pickle ] avg: 2164.0 ms / sum: 4.328 s
test[ unpickle ] avg: 1758.0 ms / sum: 3.516 s
test[ pickle_decode_encode ] avg: 3922.0 ms / sum: 7.844 sec / len: 627901 byte
test[ pickle_2 ] avg: 2429.5 ms / sum: 4.859 s
test[ unpickle_2 ] avg: 1492.5 ms / sum: 2.985 s
test[ pickle_decode_encode_2 ] avg: 3922.0 ms / sum: 7.844 sec / len: 419085 byte
test[ marshal ] avg: 31.0 ms / sum: 0.062 s
test[ unmarshal ] avg: 133.0 ms / sum: 0.266 s
test[ marshal_decode_encode ] avg: 164.0 ms / sum: 0.328 sec / len: 670091 byte
test[ simplejson ] avg: 3390.5 ms / sum: 6.781 s
test[ unsimplejson ] avg: 8086.0 ms / sum: 16.172 s
test[ simplejson_decode_encode ] avg: 11476.5 ms / sum: 22.953 sec / len: 938985 byte
test[ csv ] avg: 336.0 ms / sum: 0.672 s
test[ uncsv ] avg: 273.5 ms / sum: 0.547 s
test[ csv_decode_encode ] avg: 609.5 ms / sum: 1.219 sec / len: 428955 byte

Bei der Messung auf den Google Rechnern und lokal habe ich zwei verschiedene Array-Größen [rows] von 1.000 Zeilen und 10.000 Zeilen jeweils in zwei Durchgängen [rounds] getestet. Im Testfall wird eine Tabelle mit ein paar Spalten simuliert. Die Messung „array“ gibt an, wie lange es dauert die Tabelle als Hash-Liste zu erzeugen. Mit „pickle“ und „marshal“ wird die Zeit gemessen, um aus der Hash-Liste in ein String zu serialisieren. Analog dazu gibt „unpickle“ und „unmarshal“ die Deserialisierung des String zurück in eine Hash-Liste an.

Marshal ist 15 mal schneller beim Encode des Arrays und 5 mal schneller beim Decode des Strings, weil vermutlich dafür eine C-Implementierung und bei Pickle nur die native Python-Library genutzt wird. Das Problem an Marshal ist, dass keine Abwärtskompatibilität künftiger Python-Versionen gewährleistet wird. Das bedeutet, dass die serialisierten Daten später nicht mehr gelesen werden können. Bei einem Releasewechsel müssten alle Daten konvertiert werden.

Statt Marshal könnte auch JSON als Alternative genutzt werden. Dieses Format ist aber noch langsamer, als Pickle. Nach Marshal performt CSV ähnlich schnell für die Serialisierung von Tabellen aus Hash-Listen. CSV ist ein universelles Datenformat und so könnte der Datastore-Container auch mit Java genutzt werden.

Anhand der Messwerte kann als Nebenprodukt auch die CPU-Leistung der Google App Engine von ca. 600 MHz abgeschätzt werden.

Python-Quellcode für Testskript

#!/usr/bin/env python

import time
import pickle
import marshal
from django.utils import simplejson
import csv
import StringIO

class MyDialect(csv.Dialect):
  quotechar = '\x07'
  delimiter = ','
  lineterminator = '\n'
  doublequote = False
  skipinitialspace = False
  quoting = csv.QUOTE_NONE
  escapechar = '\\'

csv_dialect = MyDialect()

def main():
  print "Content-Type: text/plain"
  print ""

  test = {}

  def start_test(id):
    test[id] = time.time()

  def end_test(id, rounds, size=None):
    if not size is None:
      print 'test[ %s ] avg: %s ms / sum: %s sec / len: %s byte' % (
        id,
        round((time.time() - test[id]) / rounds * 1000,1),
        round(time.time() - test[id],3),
        size
      )
    else:
      print 'test[ %s ] avg: %s ms / sum: %s s' % (
        id,
        round((time.time() - test[id]) / rounds * 1000,1),
        round(time.time() - test[id],3)
      )

  def performance_test(rows,rounds):
    data = {}
    print '\nperformance_test: %s rows, %s rounds' % (
      rows, rounds
    )

    #
    #  array
    #

    start_test('array')
    round = rounds
    while round > 0: 
      round -= 1
      data = {}
      while len(data) <= rows:
        x = len(data)
        data[x] = {}
        data[x][len(data[x])] = 'Vorname'
        data[x][len(data[x])] = 'Nachname'
        data[x][len(data[x])] = 'Strasse'
        data[x][len(data[x])] = 12345
        data[x][len(data[x])] = 100.5
        data[x][len(data[x])] = None
    end_test('array',rounds)

    #
    #  pickle
    #

    start_test('pickle_decode_encode')

    start_test('pickle')
    round = rounds
    while round > 0: 
      round -= 1
      data_pickle = pickle.dumps(data)
    end_test('pickle',rounds)

    start_test('unpickle')
    round = rounds
    while round > 0: 
      round -= 1
      data_unpickle = pickle.loads(data_pickle)
    end_test('unpickle',rounds)

    end_test('pickle_decode_encode',rounds,len(data_pickle))

    #
    #  pickle (high)
    #

    start_test('pickle_decode_encode_%s' % (pickle.HIGHEST_PROTOCOL))

    start_test('pickle_%s' % (pickle.HIGHEST_PROTOCOL))
    round = rounds
    while round > 0: 
      round -= 1
      data_pickle = pickle.dumps(data,pickle.HIGHEST_PROTOCOL)
    end_test('pickle_%s' % (pickle.HIGHEST_PROTOCOL),rounds)

    start_test('unpickle_%s' % (pickle.HIGHEST_PROTOCOL))
    round = rounds
    while round > 0: 
      round -= 1
      data_unpickle = pickle.loads(data_pickle)
    end_test('unpickle_%s' % (pickle.HIGHEST_PROTOCOL),rounds)

    end_test('pickle_decode_encode_%s' % (pickle.HIGHEST_PROTOCOL),rounds,len(data_pickle))

    #
    #  marshal
    #

    start_test('marshal_decode_encode')

    start_test('marshal')
    round = rounds
    while round > 0: 
      round -= 1
      data_marshal = marshal.dumps(data)
    end_test('marshal',rounds)

    start_test('unmarshal')
    round = rounds
    while round > 0: 
      round -= 1
      data_unmarshal = marshal.loads(data_marshal)
    end_test('unmarshal',rounds)

    end_test('marshal_decode_encode',rounds,len(data_marshal))

    #
    #  simplejson
    #

    start_test('simplejson_decode_encode')

    start_test('simplejson')
    round = rounds
    while round > 0: 
      round -= 1
      data_simplejson = simplejson.dumps(data)
    end_test('simplejson',rounds)

    start_test('unsimplejson')
    round = rounds
    while round > 0: 
      round -= 1
      data_unsimplejson = simplejson.loads(data_simplejson)
    end_test('unsimplejson',rounds)

    end_test('simplejson_decode_encode',rounds,len(data_simplejson))

    #
    #  csv
    #

    start_test('csv_decode_encode')

    start_test('csv')
    round = rounds
    while round > 0: 
      round -= 1
      file_csv = StringIO.StringIO()
      dumps_csv = csv.writer(
        file_csv,
        dialect=csv_dialect
      )
      keys_csv = data[0].keys()
      keys_csv.append('__KEY___')
      dumps_csv.writerow(keys_csv)
      for key in data:
        line_csv = data[0].values()
        line_csv.append(key)
        dumps_csv.writerow(line_csv)
      file_csv.seek(0)
      data_csv = file_csv.getvalue()
      file_csv.close()
    end_test('csv',rounds)

    start_test('uncsv')
    file_csv = StringIO.StringIO(data_csv)
    loads_csv = csv.reader(
      file_csv,
      dialect=csv_dialect
    )
    data_uncsv = {}
    cols_csv = loads_csv.next()
    cols_csv.pop()
    for line_csv in loads_csv:
      key_csv = line_csv.pop()
      row_csv = dict(zip(cols_csv,line_csv))
      data_uncsv[int(key_csv)] = row_csv
    file_csv.close()
    end_test('uncsv',rounds)

    end_test('csv_decode_encode',rounds,len(data_csv))

  performance_test(1000,2)
  performance_test(10000,2)

if __name__ == "__main__":
  main()

Fazit: Mit Marshal beträgt die Latenz des Datastore-Container ca. 200 ms für das Decodieren und Encodieren der Tabelle mit 10.000 Zeilen. Ähnlich schnell ist das universelle CSV-Format. Der Tablespace wird partitioniert und auf mehrere Datastore-Container verteilt, sodass beliebig viele Datensätze gespeichert werden können. Durch das Caching der Datastore-Container im MemCache werden zusätzlich DB-Operationen gesparrt.

Advertisements

Kommentar verfassen

Trage deine Daten unten ein oder klicke ein Icon um dich einzuloggen:

WordPress.com-Logo

Du kommentierst mit Deinem WordPress.com-Konto. Abmelden / Ändern )

Twitter-Bild

Du kommentierst mit Deinem Twitter-Konto. Abmelden / Ändern )

Facebook-Foto

Du kommentierst mit Deinem Facebook-Konto. Abmelden / Ändern )

Google+ Foto

Du kommentierst mit Deinem Google+-Konto. Abmelden / Ändern )

Verbinde mit %s

%d Bloggern gefällt das: