Bash speedup

This article was first published december 24, 2020 on Medium.com in english.

Vor einigen Tagen musste ich ein Shell Script schreiben, welches 5637 Queries generiert und diese in ein einziges File quetscht. Das Ziel war, diese Queries gegen eine Datenbank laufen zu lassen und Unterschiede zu entdecken was sein sollte und was wirklich war. Aber in diesem Artikel geht es nicht um Datenbankqueries, sondern um die schiere Mächtigkeit der Bash und weshalb es manchmal Sinn macht innezuhalten und Neues auszuprobieren.

Die Situation

Der Input stammt aus einem File mit Wertepaaren. 5637 Wertepaare um genau zu sein. Diese sehen so aus:

Value1.XYZ Value2.XYZ

Das erste Feld enthält den aktuellen Wert der in der DB gespeichert ist, das zweite Feld den neuen Wert. Also kreeirte ich ein Query Template, welches in einer Schlaufe welche sämtliche Wertepaare einliest, verwendet wird und die Muster “==VALUE1==” und “==VALUE2==” mit Value1.XYZ und Value2.XYZ ersetzt:

[==TITLE==]
...
ROOT = DB/DO_SOMETHING(NEW_VALUE_NAME="==VALUE2==")
...
IMPORTANT_FIELD = DB_NAME::==VALUE1== 0
...

Das Template ist einiges grössser, aber aus Diskretion und der Übersicht zuliebe, habe ich die meisten Linien hier weggelassen. Und dann gibt es zuoberst noch das Feld “==TITLE==”, welches unique sein muss und aus einer Kombination von “Value1Value2” besteht. Das finale Query File besteht aus 90205 Zeilen.

Erster Versuch

Weil es sich nur um eine Fehlerbehebung handelte, habe ich ehrlicherweise nicht wirklich über die Performance des Loops nachgedacht. Weil ich ein Template und einen Loop benutze, war das sed für mich von Beginn weg das Werkzeug der Wahl und das Resultat sah so aus:

template=template.tpl
workcopy=workcopy.tmp
output=query.out

while read -r line
do
  array=(${line})
  value1=${array[0]}
  value2=${array[1]}
  cp ${template} ${workcopy}
  sed -i s/==TITLE==/${value1}${value2}/g ${workcopy}
  sed -i s/==VALUE1==/${value1}/g ${workcopy}
  sed -i s/==VALUE2==/${value2}/g ${workcopy}
  cat ${workcopy} >> ${output}
done < inputfile.txt

Nach einem Testlauf mit time erhielt ich folgende Messung

real    22m52.692s
user    0m33.331s
sys     1m47.344s

Wow, das dauerte lange. Fast 23 Minuten. Dieses Script wird für alle 20 DBs laufen, das heisst ich werde viel Zeit mit Warten verbringen. Ich entschloss mich das Script zu verbessern.

Zweiter Versuch

Ich verstand, dass ich in meinem ersten Versuch 5637 mal das Template ins Memory kopierte, dann wieder ins Filesystem schrieb, sed musste es von dort wieder lesen, zurück ins Memory kopieren, Änderungen berechnen, zurückschreiben, das ganze drei mal und dann musst die Workcopy nochmals vom Filesystem gelesen werden, ins Memory kopiert werden, an das Output File angehängt werden (welches ebenfalls vom Filesystem gelesen und ins Memory geladen werden muss) und dann wieder zurück ins Filesystem geschrieben werden muss. Nicht zu erwähnen, dass sed ebenfalls ins Memory geladen werden muss. Mit anderen Worten: sehr viel IO.

Also musste ich unnötigen IO loswerden und ich fokussierte mich zuerst auf die Template Kopiererei. Ich hängte einfach das Template an das bestehende Output File und ersetzte die Platzhalter. Es stellte sich als sehr schlechte Idee heraus und ich erkläre gleich weshalb. Hier ist der Code:

template=template.tpl
workcopy=workcopy.tmp
output=query.out

while read -r line
do
  array=(${line})
  value1=${array[0]}
  value2=${array[1]}
  cat ${template} >> ${output}
  sed -i s/==TITLE==/${value1}${value2}/g ${output}
  sed -i s/==VALUE1==/${value1}/g ${output}
  sed -i s/==VALUE2==/${value2}/g ${output}
done < inputfile.txt

Das Resultat war:

real    74m30.974s
user    13m8.227s
sys     46m4.676s

74 Minuten waren definitiv keine Option. Aber was war hier passiert? Ja, das unnötige Kopieren des Templates war damit vom Tisch. Aber zu was für einem Preis! Nun kopierte ich das gesamte Output File wieder und wieder vom Filesystem ins Memory und zurück. Und mit jeder Iteration werden mehr Read/Write Operationene dazuaddiert. Mit anderen Worten: diese Lösung war das Gegenteil von Skalierbar. Mit einem genügend grossen Inputfile kann das ganze System lahmgelegt werden. Also ruderte ich zurück und entschloss mich lediglich die sed Aufrufe zu optimieren.

Dritter Versuch

Die Workcopy vom Prozess zu eliminieren war eine schlechte Idee. Aber es gibt noch Raum für Verbesserungen: wenn ich die 3 sed Aufrufe optimiere, kann ich wesentlich mehr IOs sparen. Also änderte ich diese Stelle:

sed -i s/==TITLE==/${value1}${value2}/g ${workcopy}
sed -i s/==VALUE1==/${value1}/g ${workcopy}
sed -i s/==VALUE2==/${value2}/g ${workcopy}

zu:

template=template.tpl
workcopy=workcopy.tmp
output=query.out

while read -r line
do
  array=(${line})
  value1=${array[0]}
  value2=${array[1]}
  cp ${template} ${workcopy}
  sed -i "s/==TITLE==/${value1}${value2}/g; 
          s/==VALUE1==/${value1}/g; 
          s/==VALUE2==/${value2}/g" ${workcopy}
  cat ${workcopy} >> ${output}
done < inputfile.txt

Das Template wird nun wieder ins und aus dem Memory kopiert, aber bei der Optimierung von sed konnte ich n un zumindest 3 Lese/Schreibvorgänge auf einen reduzieren. Und dies endete in einem wesentlich besseren Resultat als mein erster Versuch:

real    14m26.738s
user    0m20.367s
sys     1m0.661s

Von 23 Minuten auf 15 herunter. Das war nicht schlecht, aber immer noch nicht gut. Also weshalb nicht alle Änderungen komplett im Memory machen? Kann Bash das überhaupt? Kann ich externe Tools wie sed oder awk umgehen?

Vierter Versuch

Die Antwort ist: ja, Bash kann das und sogar sehr gut.

Variablen können gefüllt werden mit Inhalt eines Files. Und Textmanipulationen können mit einer Linie Code gemacht werden. Aber es müssen ein paar Dinge beachtet werden!

Die Struktur bewahren

Wenn ich den Inhalt einer Variable ausprinte ohne doppöte Anführungszeichen, wird Bash Newlines unterdrücken. Statt eines strukturierten Text habe ich dann einen Wortsalat.

Das zweite ist, Bash schneidet mir Newlines am Anfang und Ende eines Strings ab. Liest man ein File in eine Variable ein und hängt den gleichen Inhalt immer wieder an sich selber an, muss sichergestellt werden, das das Template nicht nur eine Newline am Anfang und am ende besitzt, sondern einen Blankspace. Ich sah dies als einfachste Lösung um das Problem zu umgehen.


[==TITLE==]
...
ROOT = DB/DO_SOMETHING(NEW_VALUE_NAME="==VALUE2==")
...
IMPORTANT_FIELD = DB_NAME::==VALUE1== 0
...

Nun zum Code. Ich entledigte mich der Workcopy und des sed Aufrufs. Das Template wurde in die Variable $template eingelesen, welche wiederum bei jeder Iteration in die Variable $tempvar kopiert wurde, in welcher ich dann die Änderungen machte:

template=$(cat template.tpl)
output=query.out

while read -r line
do
  array=(${line})
  value1=${array[0]}
  value2=${array[1]}
  tempvar="$template"
  tempvar=${tempvar/==TITLE==/${value1}${value2}}
  tempvar=${tempvar/==VALUE1==/${value1}}
  tempvar=${tempvar/==VALUE2==/${value2}}
  workcopy+="${tempvar}"
done < ${pairfile}
echo "$workcopy" >> ${output}

Das Resultat war überwältigend:

real    0m6.655s
user    0m2.883s
sys     0m3.272s

6 Sekunden statt 23 Minuten. Das ist schnell.

Fazit

Man wird sich fragen: weshalb der ganze Aufwand und nicht gleich von Beginn weg eine Sprache wie Perl oder Python verwenden? Und ich muss zugeben, ich bin selbst Fan dieser Sprachen. Aber manchmal muss es eben einfach ein Shell Script sein. Und dann bietet Shell durchaus sehr gute Möglichkeiten um eine beeindruckende Geschwindigkeit zu erreichen.

Wenn Du es bisher geschafft hast, so möchte ich mich bei Dir bedanken und ich hoffe, dieser Artikel wird dir eines Tages helfen und dir einige Zeit ersparen!

Schreibe einen Kommentar