Effizientere Inferenz auf CPUs
Ein umfangreicher Vergleich verschiedener Methoden zur Beschleunigung der Inferenz von ML-Modellen auf CPUs.
Maschinelles Lernen ist ein kostspieliges Unterfangen, bei dem während des gesamten Lebenszyklus eines Modells Kosten anfallen. Erstens gibt es den kostenintensiven Trainingsprozess, der nicht nur aus dem Trainieren des Modells besteht, sondern auch aus dem Iterieren an der Modellarchitektur, dem Entwickeln der Datenpipeline und dem Fine-Tuning der Hyperparameter. Zweitens wird das Modell nach dem Training produktiv eingesetzt für Inferenz-Workloads, wodurch im Gegensatz zum Training, laufende Kosten anfallen. Daher ist es wichtig, die Modellinferenz so ressourceneffizient wie möglich zu gestalten. Eine Möglichkeit hierfür ist das Verwenden von CPUs anstelle der wesentlich teureren GPUs, was insbesondere für kleinere Modelle gut funktioniert. In diesem Artikel werde ich mehrere Methoden testen, die versprechen, ML-Modelle effizienter auf CPUs auszuführen.
Warum es Graphen braucht
In älteren ML-Frameworks, wie TensorFlow 1.0, mussten Modelle als Graphen spezifiziert werden, die anschliessend vom Framework optimiert und kompiliert wurden. Dabei wird das Modell ausserhalb des Control-Flows des Python-Codes ausgeführt, was die Fehlersuche und Iteration der Modellarchitektur erschwert. PyTorch hingegen wählte einen anderen Ansatz. Modelle werden «eager» ausgeführt, also Zeile für Zeile wie in einem normalen Python-Programm, was es Data Scientists ermöglicht, ihre Modelle einfacher zu debuggen, da sie nach jeder Zeile Zwischenresultate inspizieren können. Daher ist es nicht verwunderlich, dass PyTorch mit seinem Eager-Modus zum beliebtesten ML-Framework wurde.
Jedoch haben Eager-Modelle auch einen grossen Nachteil: Sie sind sowohl für das Trainieren als auch für die Inferenz schwieriger zu optimieren. Deshalb bietet PyTorch Möglichkeiten an, um Eager-Modelle in Graphen umzuwandeln, da diese sich besser eignen, um mögliche Optimierungen zu finden, wie das Fusionieren von vielen Operationen in eine einzige. So bietet PyTorch z. B. die JIT-Kompilierung durch Tracing an, wobei es jedoch nicht möglich ist, den Kontrollfluss des Modells zu erfassen, falls dieser von Zwischenresultate des Modells abhängt. Alternativ zur JIT-Kompilierung gibt es die Möglichkeit, das Modell in TorchScript umzuschreiben, ein Subset der Python-Programmiersprache, das just-in-time kompiliert werden kann und für das ein Graph erzeugt wird. Beide Methoden haben jedoch ihre eigenen Nachteile. So ist das Umschreiben von Modellen in TorchScript zeitintensiv und das Tracing fehleranfällig und limitiert. Glücklicherweise gibt es seit dem Release von PyTorch 2.0 einen dritten Weg mittels torch compile, der dank des Verwendens von TorchDynamo den Graphen der Rechenschritte des Modells zuverlässiger erfasst, da er mittels einer Integration in den Bytecode-Interpreter von Python erstellt wird. Auch diese Lösung ist nicht perfekt, jedoch ist sie einfacher zu verwenden und zuverlässiger als die vorherigen Lösungen.
Die Testmethodik
Die verschiedenen Lösungen für schnellere Inferenz auf CPUs wurden mittels zweier Modelle verglichen: ein vortrainiertes ResNet 101 Modell für Bildklassifizierung und BERT für Sequenzklassifizierung, welches mittels dieses Hugging Face Tutorial erstellt wurde. Die Wahl fiel auf diese Modelle, da sie Konvolutions- und Transformers-Architekturen verwenden, die zwei der meistverbreiteten Architekturen sind. Um die einzelnen Methoden zu vergleichen, wird die Inferenzgeschwindigkeit des ResNet-Modells mit 3200 Bilder aus dem Imagenette Datensatz gemessen und die Geschwindigkeit von BERT mit 1000 Beispielen aus dem IMDB Datensatz, welcher schon für dessen Training verwendet wurde.
Torch Compile und Export
PyTorch bietet mit Torch Compile und Export zwei praktische Wege, um ML-Modelle zu optimieren. Beide verwenden im Hintergrund TorchDynamo, um den Graphen der Rechenschritte zu erfassen, aber während Torch Compile auf den Eager-Modus zurückfällt für Teile des Modells, die nicht als Graph erfasst werden konnten, muss mit Torch Export der ganze Graph erstellt werden. Des Weiteren wird mit Torch Export der Graph vollumfänglich mit funktionalen PyTorch ATen Operationen definiert. Somit geht Torch Export deutlich weiter in seinen Anforderungen, wodurch es schwerer ist, ein Modell zu konvertieren. Für BERT war ich zum Beispiel nicht in der Lage, das Modell zu exportieren, obwohl ich auch probierte, die dynamischen Achsen des Modells korrekt zu spezifizieren, was notwendig ist, damit es richtig für Eingaben von unterschiedlichen Grössen funktioniert. Für ResNet hingegen ging die Konvertierung ohne Probleme.
Für ResNet übertrafen sowohl die kompilierten als auch die exportierten Modelle das nicht optimierte Modell bei einer Batch-Grösse von eins. Allerdings erreichte dies nur das exportierte Modell, auch für grössere Batches. Dennoch profitierten alle drei Methoden von einer höheren Batch-Grösse, vermutlich dank einer effizienteren Nutzung von Caches während der Konvolution-Operationen. Es ist ausserdem wichtig zu beachten, dass Torch Compile primär entwickelt wurde, um das Training zu beschleunigen und nicht die Inferenz. Daher waren diese Ergebnisse zu erwarten.
Wie bereits erwähnt, konnte ich BERT nur kompilieren, jedoch nicht exportieren, was erwarteterweise auch zu keiner Beschleunigung führte, sogar nicht für die Batch-Grösse von eins. Anders als bei ResNet gibt es auf CPUs auch keinen Vorteil durch die Verwendung von grösseren Batches. Stattdessen, führen sie sogar zu einer Zunahme der Laufzeiten, was vermutlich darauf zurückzuführen ist, dass kürzere Sätze aufgefüllt werden auf die Länge des längsten Satzes im Batch. Daher eignen sich Batch-Jobs an sich nicht, um ML Modelle auf CPUs zu beschleunigen. Für GPUs sieht dies in der Regel ein wenig anders aus, da bei denen oft die Datentransfers die Rechenzeiten dominieren, für kleine Batches.
Für beide Modelle hatte das Kompilieren keinen oder einen vernachlässigbaren Einfluss auf die Genauigkeit der Ausgaben des Modells. Die Beschleunigungen bei ResNet gehen somit ohne Einbussen in der Modellleistung einher.
OpenVino
OpenVINO ist ein Python-Toolkit, entwickelt von Intel, für das Optimieren von Modellen für ihre CPU- und GPU-Architekturen. Die Modelle können dabei entweder in einem standardisierten Format, wie ONNX, zur Verfügung gestellt werden, oder sie können von populären ML-Frameworks wie PyTorch, TensorFlow, oder JAX importiert werden. Für PyTorch kann diese Konversion entweder mittels des OpenVINO Backends für Torch Compile erfolgen, oder mittels einer Konversionsfunktion, die OpenVINO zur Verfügung stellt. Beide Methoden wurden in meinen Experimenten getestet.
Für ResNet übertrafen sowohl das mit der OpenVINO-SDK optimierte Modell als auch das mit dem OpenVINO-Backend für Torch Compile erstellte Modell das unoptimierte Modell. Ersterer Ansatz führte zu einer Beschleunigung von über 100 % bei einer Batch-Grösse von eins und über 60 % bei grösseren Batches. Die Beschleunigungen der kompilierten Version fallen weniger beeindruckend aus und liegen nur zwischen 17 und 36 Prozent.
Für BERT konnte ich das Modell nicht mit dem OpenVINO-SDK exportieren, von daher werden lediglich die Resultate für das OpenVINO-Backend in Torch Compiled gezeigt, welche bei einer Batch-Grösse von eins eine moderate Leistungssteigerung von 8 % zeigen und bei grösseren Batches keinerlei Leistungsgewinne nachweisen.
Auch für diese Experimente hatten die Optimierungen keinen Einfluss auf die Genauigkeit der Modelle, wodurch dieses Verfahren eine weitere leistungsneutrale Optimierungsstrategie liefert.
IPEX
Die Intel Extension für PyTorch, IPEX, ist ein anderes Open-Source Projekt von Intel, das versucht, PyTorch Modelle mittels CPU-Instruktionen für Vektoroperationen und anderer ML-spezifischer Operationen zu beschleunigen. Diese Optimierungen werden über das IPEX-Backend für Torch Compile auf das Modell angewandt. Des Weiteren bietet die IPEX-SDK die Möglichkeit, andere Optimierungen, wie das Fusionieren von Operatoren oder das Verwenden von kompletten spezialisierten Layers. Für meine Experimente wurden die Standardoptimierungen verwendet, deren Resultate in den folgenden Grafiken zu sehen sind.
Wie bisher funktioniert auch diese Optimierungsmethode ohne Probleme für ResNet und zeigt einen deutlichen Gewinn an Leistung im Vergleich zur Baseline, der am grössten ist für die Batch-Grösse von acht mit 42 %.
Unglücklicherweise hat auch diese Optimierungsmethode keinen Einfluss auf die Geschwindigkeit der Inferenz mit BERT.
ONNX
Das Erstellen von ONNX Modellen wird unterstützt von den meisten bedeutenden ML-Frameworks, wie PyTorch, TensorFlow, und Scikit-Learn. Jedoch, ist es nicht immer möglich, alle Modelle ins ONNX Format zu exportieren, wie dieser Artikel für Scikit-Learn zeigt. Falls ein Export nicht möglich ist, kann gegebenenfalls auf benutzerdefinierte Operatoren zurückgegriffen werden, falls die gewählte ONNX Runtime diese unterstützt. Jedoch ist dies ein signifikanter Mehraufwand und ein weiterer Umstand für die Verwendung von ONNX, wenn man berücksichtigt, dass man für das Verwenden von ONNX auch Tensoren mit einer dynamischen Dimension bestimmen muss, also zum Beispiel die Batch-Grösse für Eingaben.
Für meine Experimente konnte das ResNet Modell ohne Probleme mittels dem TrochDynamo Backend für ONNX exportiert werden. Jedoch war das gleiche Vorgehen für BERT nicht möglich. Stattdessen müsste Hugging Face Optimum verwendet werden, um eine ONNX-Version des Modells zu beziehen. Solche Probleme machen es schwer, ONNX als standardisiertes Format innerhalb eines Unternehmens zu verwenden, da jedes Data Science Team garantieren müsste, dass ihre Modelle konvertierbar sind. Für einige Teams würde das bedeuten, sie sind entweder limitiert in den Modelltypen, die sie verwenden könnten, oder sie müssen benutzerdefinierte Operatoren für ihre Modelle schreiben.
ONNX selbst ist nur ein Format, deshalb wird eine Runtime benötigt, um ONNX Modelle zu verwenden. Für meine Experimente habe ich dazu die ONNX Runtime von Microsoft verwendet, die fähig ist, Modelle auf unterschiedlichen CPUs, GPUs oder ML-optimierter Hardware laufen zu lassen.
Die Resultate des ONNX Modells für ResNet sind interessant und unterscheiden sich von dem, was wir bis jetzt gesehen haben. Für eine Batch-Grösse von eins ist sie fast so schnell wie OpenVINO, jedoch gibt es nahezu keinen Leistungszuwachs für grössere Batches.
Für das BERT Modell gibt es dieses Mal die ersten spannenden Resultate. Wie bereits für OpenVINO gibt es einen kleinen Leistungsgewinn bei einer Batch-Grösse von eins. Jedoch scheitert die ONNX Runtime komplett für grössere Batches, wodurch die Inferenz langsamer ist als für das nicht optimierte Modell. Als kleiner Zusatz gibt es für BERT auch Resultate, für die OpenVINO verwendet würde mit dem ONNX Modell und diese enttäuschen nicht. Es ist die einzige Optimierung, die für BERT deutliche Leistungsgewinne hervorbringt und selbst für grössere Batches noch kleinere Gewinne mit sich bringt.
Die Experimente mit ONNX zeigen, dass die prinzipielle Stärke des Formats kleine Batches sind. Für grössere Batches sind andere Methoden, wie OpenVINO, deutlich performanter.
Fazit
Das Optimieren von Modellen für die Inferenz auf CPUs ist nicht so unkompliziert, wie erhofft. Obwohl es viele Methoden dafür gibt, wovon einige in diesem Artikel ausprobiert wurden, können alle Probleme für ein spezifisches Modell haben. Für viele der Methoden wird zudem ein gutes Verständnis des Modells vorausgesetzt, um zum Beispiel die dynamischen Dimensionen zu bestimmen. Die leistungsstärkste Lösung für beide Modelle in meinen Experimenten war OpenVINO, vor allem, wenn es noch mit ONNX kombiniert wird. Jedoch ist OpenVINO konzipiert für Intel-Hardware. Für andere Plattformen bietet die ONNX Runtime wohl die beste Lösung, falls keine grossen Batches verwendet werden. Die übergreifende gute Nachricht von allen Experimenten ist hingegen, dass keines der getesteten Verfahren einen negativen Einfluss auf die Genauigkeit der Modelle hat.
Bei Interesse daran, wie man diese Optimierungen anwendet, kann mein GitHub Repository, ML-Inference-Experiments, als Ausgangspunkt dienen. Es beinhaltet alle Experimente, die in diesem Artikel gezeigt wurden.
Effizientere Inferenz auf CPUs © 2025 by Jeffrey Wigger is licensed under CC BY 4.0