GPUs sind ein knappes Gut, seit große Modelle ein heißer Trend geworden sind. Die Rücklagen vieler Unternehmen reichen nicht unbedingt aus, geschweige denn einzelner Entwickler. Gibt es eine Möglichkeit, Rechenleistung zu nutzen, um Modelle effizienter zu trainieren?
In einem kürzlich erschienenen Blog stellte Sebastian Raschka die Methode der „Gradientenakkumulation“ vor, die bei begrenztem GPU-Speicher ein Trainingsmodell mit größerer Batchgröße verwenden und so Hardwareeinschränkungen umgehen kann.
Zuvor veröffentlichte Sebastian Raschka auch einen Artikel über die Verwendung von Multi-GPU-Trainingsstrategien zur Beschleunigung der Feinabstimmung von Sprachmodellen in großem Maßstab, einschließlich Mechanismen wie Modell- oder Tensor-Sharding, die Modellgewichte und Berechnungen auf verschiedene Geräte verteilen, um GPU-Probleme zu lösen . Speicherlimit.
Feinabstimmung des BLOOM-Modells für die Klassifizierung
Angenommen, wir sind daran interessiert, kürzlich vorab trainierte große Sprachmodelle für nachgelagerte Aufgaben wie die Textklassifizierung zu übernehmen. Dann könnten wir uns für die Open-Source-Alternative zu GPT-3 entscheiden, das BLOOM-Modell, konkret die BLOOM-Version mit „nur“ 560 Millionen Parametern – die problemlos in den RAM einer herkömmlichen GPU passen sollte (Google Colab kostenlos). Version verfügt über eine GPU mit 15 GB RAM).
Sobald Sie anfangen, werden Sie wahrscheinlich auf ein Problem stoßen: Der Speicher wird während des Trainings oder der Feinabstimmung schnell wachsen. Die einzige Möglichkeit, dieses Modell zu trainieren, besteht darin, die Batch-Größe auf 1 zu setzen (Batch-Größe = 1).
Der Code zur Feinabstimmung von BLOOM für die Zielklassifizierungsaufgabe unter Verwendung einer Stapelgröße von 1 (Stapelgröße = 1) ist unten dargestellt. Sie können den vollständigen Code auch von der GitHub-Projektseite herunterladen:
https://github.com/rasbt/gradient-accumulation-blog/blob/main/src/1_batchsize-1.py
Sie können diesen Code kopieren und direkt in Google Colab einfügen, müssen aber auch die zugehörige Datei „local_dataset_utilities.py“ per Drag & Drop in denselben Ordner ziehen, aus dem Sie einige Datensatz-Dienstprogramme importiert haben.
# pip install torch lightning matplotlib pandas torchmetrics watermark transformers datasets -U
import os
import os.path as op
import time
from datasets import load_dataset
from lightning import Fabric
import torch
from torch.utils.data import DataLoader
import torchmetrics
from transformers import AutoTokenizer
from transformers import AutoModelForSequenceClassification
from watermark import watermark
from local_dataset_utilities import download_dataset, load_dataset_into_to_dataframe, partition_dataset
from local_dataset_utilities import IMDBDataset
def tokenize_text (batch):
return tokenizer (batch ["text"], truncation=True, padding=True, max_length=1024)
def train (num_epochs, model, optimizer, train_loader, val_loader, fabric):
for epoch in range (num_epochs):
train_acc = torchmetrics.Accuracy (
task="multiclass", num_classes=2).to (fabric.device)
for batch_idx, batch in enumerate (train_loader):
model.train ()
### FORWARD AND BACK PROP
outputs = model (
batch ["input_ids"],
attention_mask=batch ["attention_mask"],
labels=batch ["label"]
)
fabric.backward (outputs ["loss"])
### UPDATE MODEL PARAMETERS
optimizer.step ()
optimizer.zero_grad ()
### LOGGING
if not batch_idx % 300:
print (f"Epoch: {epoch+1:04d}/{num_epochs:04d}"
f"| Batch {batch_idx:04d}/{len (train_loader):04d}"
f"| Loss: {outputs ['loss']:.4f}")
model.eval ()
with torch.no_grad ():
predicted_labels = torch.argmax (outputs ["logits"], 1)
train_acc.update (predicted_labels, batch ["label"])
### MORE LOGGING
model.eval ()
with torch.no_grad ():
val_acc = torchmetrics.Accuracy (task="multiclass", num_classes=2).to (fabric.device)
for batch in val_loader:
outputs = model (
batch ["input_ids"],
attention_mask=batch ["attention_mask"],
labels=batch ["label"]
)
predicted_labels = torch.argmax (outputs ["logits"], 1)
val_acc.update (predicted_labels, batch ["label"])
print (f"Epoch: {epoch+1:04d}/{num_epochs:04d}"
f"| Train acc.: {train_acc.compute ()*100:.2f}%"
f"| Val acc.: {val_acc.compute ()*100:.2f}%"
)
train_acc.reset (), val_acc.reset ()
if __name__ == "__main__":
print (watermark (packages="torch,lightning,transformers", python=True))
print ("Torch CUDA available?", torch.cuda.is_available ())
device = "cuda" if torch.cuda.is_available () else "cpu"
torch.manual_seed (123)
# torch.use_deterministic_algorithms (True)
##########################
### 1 Loading the Dataset
##########################
download_dataset ()
df = load_dataset_into_to_dataframe ()
if not (op.exists ("train.csv") and op.exists ("val.csv") and op.exists ("test.csv")):
partition_dataset (df)
imdb_dataset = load_dataset (
"csv",
data_files={
"train": "train.csv",
"validation": "val.csv",
"test": "test.csv",
},
)
#########################################
### 2 Tokenization and Numericalization
#########################################
tokenizer = AutoTokenizer.from_pretrained ("bigscience/bloom-560m", max_length=1024)
print ("Tokenizer input max length:", tokenizer.model_max_length, flush=True)
print ("Tokenizer vocabulary size:", tokenizer.vocab_size, flush=True)
print ("Tokenizing ...", flush=True)
imdb_tokenized = imdb_dataset.map (tokenize_text, batched=True, batch_size=None)
del imdb_dataset
imdb_tokenized.set_format ("torch", columns=["input_ids", "attention_mask", "label"])
os.environ ["TOKENIZERS_PARALLELISM"] = "false"
#########################################
### 3 Set Up DataLoaders
#########################################
train_dataset = IMDBDataset (imdb_tokenized, partition_key="train")
val_dataset = IMDBDataset (imdb_tokenized, partition_key="validation")
test_dataset = IMDBDataset (imdb_tokenized, partition_key="test")
train_loader = DataLoader (
dataset=train_dataset,
batch_size=1,
shuffle=True,
num_workers=4,
drop_last=True,
)
val_loader = DataLoader (
dataset=val_dataset,
batch_size=1,
num_workers=4,
drop_last=True,
)
test_loader = DataLoader (
dataset=test_dataset,
batch_size=1,
num_workers=2,
drop_last=True,
)
#########################################
### 4 Initializing the Model
#########################################
fabric = Fabric (accelerator="cuda", devices=1, precision="16-mixed")
fabric.launch ()
model = AutoModelForSequenceClassification.from_pretrained (
"bigscience/bloom-560m", num_labels=2)
optimizer = torch.optim.Adam (model.parameters (), lr=5e-5)
model, optimizer = fabric.setup (model, optimizer)
train_loader, val_loader, test_loader = fabric.setup_dataloaders (
train_loader, val_loader, test_loader)
#########################################
### 5 Finetuning
#########################################
start = time.time ()
train (
num_epochs=1,
model=model,
optimizer=optimizer,
train_loader=train_loader,
val_loader=val_loader,
fabric=fabric,
)
end = time.time ()
elapsed = end-start
print (f"Time elapsed {elapsed/60:.2f} min")
with torch.no_grad ():
model.eval ()
test_acc = torchmetrics.Accuracy (task="multiclass", num_classes=2).to (fabric.device)
for batch in test_loader:
outputs = model (
batch ["input_ids"],
attention_mask=batch ["attention_mask"],
labels=batch ["label"]
)
predicted_labels = torch.argmax (outputs ["logits"], 1)
test_acc.update (predicted_labels, batch ["label"])
print (f"Test accuracy {test_acc.compute ()*100:.2f}%")
Der Autor hat Lightning Fabric verwendet, weil es Entwicklern ermöglicht, die Anzahl der GPUs und die Multi-GPU-Trainingsstrategie flexibel zu ändern, wenn dieser Code auf unterschiedlicher Hardware ausgeführt wird. Es ermöglicht auch die Aktivierung eines Trainings mit gemischter Präzision, indem einfach die Präzisionsflagge angepasst wird. In diesem Fall kann ein Training mit gemischter Präzision die Trainingsgeschwindigkeit verdreifachen und den Speicherbedarf um etwa 25 % reduzieren.
Der oben gezeigte Hauptcode wird in der Hauptfunktion ausgeführt (im Kontext von if __name__ == „__main__“). Auch wenn nur eine einzelne GPU verwendet wird, wird empfohlen, die PyTorch-Laufzeitumgebung zu verwenden, um ein Training mit mehreren GPUs durchzuführen. Dann sind die folgenden drei Codeabschnitte, die in if __name__ == "__main__" eingebunden sind, für das Laden der Daten verantwortlich:
# 1 Laden Sie den Datensatz
#2 Tokenisierung und Digitalisierung
# 3 Richten Sie den Datenlader ein
In Abschnitt 4 geht es um die Initialisierung des Modells, und dann wird in Abschnitt 5, Feinabstimmung, die Zugfunktion aufgerufen, und hier wird es interessant. In der Funktion train (...) ist eine Standard-PyTorch-Schleife implementiert. Eine kommentierte Version der Kerntrainingsschleife sieht folgendermaßen aus:
Das Problem bei einer Batch-Größe von 1 (Batch-Größe = 1) besteht darin, dass Gradientenaktualisierungen sehr chaotisch und schwierig werden können, wie man an schwankenden Trainingsverlusten und schlechter Leistung des Testsatzes beim Training des folgenden Modells erkennen kann:
...
torch : 2.0.0
lightning : 2.0.0
transformers: 4.27.2
Torch CUDA available? True
...
Epoch: 0001/0001 | Batch 23700/35000 | Loss: 0.0969
Epoch: 0001/0001 | Batch 24000/35000 | Loss: 1.9902
Epoch: 0001/0001 | Batch 24300/35000 | Loss: 0.0395
Epoch: 0001/0001 | Batch 24600/35000 | Loss: 0.2546
Epoch: 0001/0001 | Batch 24900/35000 | Loss: 0.1128
Epoch: 0001/0001 | Batch 25200/35000 | Loss: 0.2661
Epoch: 0001/0001 | Batch 25500/35000 | Loss: 0.0044
Epoch: 0001/0001 | Batch 25800/35000 | Loss: 0.0067
Epoch: 0001/0001 | Batch 26100/35000 | Loss: 0.0468
Epoch: 0001/0001 | Batch 26400/35000 | Loss: 1.7139
Epoch: 0001/0001 | Batch 26700/35000 | Loss: 0.9570
Epoch: 0001/0001 | Batch 27000/35000 | Loss: 0.1857
Epoch: 0001/0001 | Batch 27300/35000 | Loss: 0.0090
Epoch: 0001/0001 | Batch 27600/35000 | Loss: 0.9790
Epoch: 0001/0001 | Batch 27900/35000 | Loss: 0.0503
Epoch: 0001/0001 | Batch 28200/35000 | Loss: 0.2625
Epoch: 0001/0001 | Batch 28500/35000 | Loss: 0.1010
Epoch: 0001/0001 | Batch 28800/35000 | Loss: 0.0035
Epoch: 0001/0001 | Batch 29100/35000 | Loss: 0.0009
Epoch: 0001/0001 | Batch 29400/35000 | Loss: 0.0234
Epoch: 0001/0001 | Batch 29700/35000 | Loss: 0.8394
Epoch: 0001/0001 | Batch 30000/35000 | Loss: 0.9497
Epoch: 0001/0001 | Batch 30300/35000 | Loss: 0.1437
Epoch: 0001/0001 | Batch 30600/35000 | Loss: 0.1317
Epoch: 0001/0001 | Batch 30900/35000 | Loss: 0.0112
Epoch: 0001/0001 | Batch 31200/35000 | Loss: 0.0073
Epoch: 0001/0001 | Batch 31500/35000 | Loss: 0.7393
Epoch: 0001/0001 | Batch 31800/35000 | Loss: 0.0512
Epoch: 0001/0001 | Batch 32100/35000 | Loss: 0.1337
Epoch: 0001/0001 | Batch 32400/35000 | Loss: 1.1875
Epoch: 0001/0001 | Batch 32700/35000 | Loss: 0.2727
Epoch: 0001/0001 | Batch 33000/35000 | Loss: 0.1545
Epoch: 0001/0001 | Batch 33300/35000 | Loss: 0.0022
Epoch: 0001/0001 | Batch 33600/35000 | Loss: 0.2681
Epoch: 0001/0001 | Batch 33900/35000 | Loss: 0.2467
Epoch: 0001/0001 | Batch 34200/35000 | Loss: 0.0620
Epoch: 0001/0001 | Batch 34500/35000 | Loss: 2.5039
Epoch: 0001/0001 | Batch 34800/35000 | Loss: 0.0131
Epoch: 0001/0001 | Train acc.: 75.11% | Val acc.: 78.62%
Time elapsed 69.97 min
Test accuracy 78.53%
Was kann getan werden, um Modelle mit größeren Batchgrößen zu trainieren, da nicht viele GPUs für Tensor-Sharding verfügbar sind?
Eine solche Lösung ist die Gradientenakkumulation, die die oben genannte Trainingsschleife modifiziert.
Was ist Gradientenakkumulation?
Die Gradientenakkumulation ist eine Möglichkeit, die Batch-Größe während des Trainings virtuell zu erhöhen. Dies ist nützlich, wenn der verfügbare GPU-Speicher nicht ausreicht, um die gewünschte Batch-Größe aufzunehmen. Bei der Gradientenakkumulation werden Gradienten für kleinere Chargen berechnet und über mehrere Iterationen akkumuliert (normalerweise summiert oder gemittelt), anstatt die Modellgewichte nach jeder Charge zu aktualisieren. Sobald der kumulative Gradient die angestrebte „virtuelle“ Stapelgröße erreicht, werden die Modellgewichte mithilfe des kumulativen Gradienten aktualisiert.
Sehen Sie sich die aktualisierte PyTorch-Trainingsschleife unten an:
Wenn „akkumulation_steps“ auf 2 gesetzt ist, werden „zero_grad()“ und „optimierer.step()“ nur jede Sekunde aufgerufen. Daher hat das Ausführen der geänderten Trainingsschleife mit „akkumulation_steps=2“ den gleichen Effekt wie eine Verdoppelung der Stapelgröße.
Wenn Sie beispielsweise eine Stapelgröße von 256 verwenden möchten, aber nur eine Stapelgröße von 64 in den GPU-Speicher passen, können Sie eine Gradientenakkumulation für vier Stapel der Größe 64 durchführen. (Wenn alle vier Stapel verarbeitet wurden, entsprechen die kumulativen Gradienten einer einzelnen Stapelgröße von 256.) Dadurch werden effektiv größere Stapelgrößen simuliert, ohne dass ein größerer GPU-Speicher erforderlich ist oder Tensoren auf verschiedene Geräte aufgeteilt werden. Stück.
Während die Gradientenakkumulation uns dabei helfen kann, Modelle mit größeren Stapelgrößen zu trainieren, verringert sie nicht den gesamten Rechenaufwand. Tatsächlich kann es manchmal dazu führen, dass der Trainingsprozess etwas langsamer abläuft, weil Gewichtsaktualisierungen seltener durchgeführt werden. Dennoch hilft es uns, die Einschränkung zu umgehen, dass sehr kleine Batchgrößen häufige und chaotische Updates verursachen.
Lassen Sie uns nun beispielsweise den obigen Code mit einer Stapelgröße von 1 ausführen, was 16 Akkumulationsschritte erfordert, um eine Stapelgröße von 16 zu simulieren.
Die Ausgabe ist wie folgt:
...
torch : 2.0.0
lightning : 2.0.0
transformers: 4.27.2
Torch CUDA available? True
...
Epoch: 0001/0001 | Batch 23700/35000 | Loss: 0.0168
Epoch: 0001/0001 | Batch 24000/35000 | Loss: 0.0006
Epoch: 0001/0001 | Batch 24300/35000 | Loss: 0.0152
Epoch: 0001/0001 | Batch 24600/35000 | Loss: 0.0003
Epoch: 0001/0001 | Batch 24900/35000 | Loss: 0.0623
Epoch: 0001/0001 | Batch 25200/35000 | Loss: 0.0010
Epoch: 0001/0001 | Batch 25500/35000 | Loss: 0.0001
Epoch: 0001/0001 | Batch 25800/35000 | Loss: 0.0047
Epoch: 0001/0001 | Batch 26100/35000 | Loss: 0.0004
Epoch: 0001/0001 | Batch 26400/35000 | Loss: 0.1016
Epoch: 0001/0001 | Batch 26700/35000 | Loss: 0.0021
Epoch: 0001/0001 | Batch 27000/35000 | Loss: 0.0015
Epoch: 0001/0001 | Batch 27300/35000 | Loss: 0.0008
Epoch: 0001/0001 | Batch 27600/35000 | Loss: 0.0060
Epoch: 0001/0001 | Batch 27900/35000 | Loss: 0.0001
Epoch: 0001/0001 | Batch 28200/35000 | Loss: 0.0426
Epoch: 0001/0001 | Batch 28500/35000 | Loss: 0.0012
Epoch: 0001/0001 | Batch 28800/35000 | Loss: 0.0025
Epoch: 0001/0001 | Batch 29100/35000 | Loss: 0.0025
Epoch: 0001/0001 | Batch 29400/35000 | Loss: 0.0000
Epoch: 0001/0001 | Batch 29700/35000 | Loss: 0.0495
Epoch: 0001/0001 | Batch 30000/35000 | Loss: 0.0164
Epoch: 0001/0001 | Batch 30300/35000 | Loss: 0.0067
Epoch: 0001/0001 | Batch 30600/35000 | Loss: 0.0037
Epoch: 0001/0001 | Batch 30900/35000 | Loss: 0.0005
Epoch: 0001/0001 | Batch 31200/35000 | Loss: 0.0013
Epoch: 0001/0001 | Batch 31500/35000 | Loss: 0.0112
Epoch: 0001/0001 | Batch 31800/35000 | Loss: 0.0053
Epoch: 0001/0001 | Batch 32100/35000 | Loss: 0.0012
Epoch: 0001/0001 | Batch 32400/35000 | Loss: 0.1365
Epoch: 0001/0001 | Batch 32700/35000 | Loss: 0.0210
Epoch: 0001/0001 | Batch 33000/35000 | Loss: 0.0374
Epoch: 0001/0001 | Batch 33300/35000 | Loss: 0.0007
Epoch: 0001/0001 | Batch 33600/35000 | Loss: 0.0341
Epoch: 0001/0001 | Batch 33900/35000 | Loss: 0.0259
Epoch: 0001/0001 | Batch 34200/35000 | Loss: 0.0005
Epoch: 0001/0001 | Batch 34500/35000 | Loss: 0.4792
Epoch: 0001/0001 | Batch 34800/35000 | Loss: 0.0003
Epoch: 0001/0001 | Train acc.: 78.67% | Val acc.: 87.28%
Time elapsed 51.37 min
Test accuracy 87.37%
Den obigen Ergebnissen zufolge ist die Verlustschwankung geringer als zuvor. Darüber hinaus verbesserte sich die Leistung des Testsatzes um 10 %. Da der Trainingssatz nur einmal iteriert wird, wird jedes Trainingsbeispiel nur einmal angetroffen. Trainingsmodelle für mehrere Epochen können die Vorhersageleistung weiter verbessern.
Möglicherweise stellen Sie auch fest, dass dieser Code auch schneller ausgeführt wird als der zuvor verwendete Code der Stapelgröße 1. Wenn wir die virtuelle Stapelgröße mithilfe der Gradientenakkumulation auf 8 erhöhen, gibt es immer noch die gleiche Anzahl an Vorwärtsdurchgängen. Da das Modell jedoch nur alle acht Epochen aktualisiert wird, gibt es weniger Rückwärtsdurchläufe, was eine schnellere Iteration über Stichproben innerhalb einer Epoche (Anzahl der Trainingsrunden) ermöglicht.
abschließend
Die Gradientenakkumulation ist eine Technik, die größere Chargengrößen simuliert, indem mehrere kleine Chargengradienten akkumuliert werden, bevor Gewichtsaktualisierungen durchgeführt werden. Diese Technik ist hilfreich, wenn der verfügbare Speicher begrenzt ist und die Stapelgröße, die in den Speicher passt, klein ist.
Aber stellen Sie sich zunächst ein Szenario vor, in dem Sie mit einer Batch-Größe arbeiten können, was bedeutet, dass der verfügbare Speicher groß genug ist, um die gewünschte Batch-Größe aufzunehmen. In diesem Fall ist eine Gradientenakkumulation möglicherweise nicht erforderlich. Tatsächlich kann es effizienter sein, mit einer größeren Batch-Größe zu arbeiten, da es mehr Parallelität ermöglicht und die Anzahl der zum Trainieren des Modells erforderlichen Gewichtsaktualisierungen reduziert.
Zusammenfassend lässt sich sagen, dass die Gradientenakkumulation eine praktische Technik ist, die verwendet werden kann, um die Auswirkungen des Rauschens bei Mini-Batch-Größen auf die Genauigkeit von Gradientenaktualisierungen zu reduzieren. Dies ist bei weitem eine einfache und effektive Technik, die es uns ermöglicht, Hardware-Einschränkungen zu umgehen.
PS: Kann man das schneller machen?
Kein Problem. Mithilfe von Torch.compile, das in PyTorch 2.0 eingeführt wurde, kann es noch schneller ausgeführt werden. Sie müssen nur etwas model = Torch.compile hinzufügen, wie im Bild unten gezeigt:
Das vollständige Skript ist auf GitHub verfügbar.
In diesem Fall spart Torch.compile weitere zehn Minuten Trainingszeit ein, ohne die Modellierungsleistung zu beeinträchtigen:
poch: 0001/0001 | Batch 26400/35000 | Loss: 0.0320
Epoch: 0001/0001 | Batch 26700/35000 | Loss: 0.0010
Epoch: 0001/0001 | Batch 27000/35000 | Loss: 0.0006
Epoch: 0001/0001 | Batch 27300/35000 | Loss: 0.0015
Epoch: 0001/0001 | Batch 27600/35000 | Loss: 0.0157
Epoch: 0001/0001 | Batch 27900/35000 | Loss: 0.0015
Epoch: 0001/0001 | Batch 28200/35000 | Loss: 0.0540
Epoch: 0001/0001 | Batch 28500/35000 | Loss: 0.0035
Epoch: 0001/0001 | Batch 28800/35000 | Loss: 0.0016
Epoch: 0001/0001 | Batch 29100/35000 | Loss: 0.0015
Epoch: 0001/0001 | Batch 29400/35000 | Loss: 0.0008
Epoch: 0001/0001 | Batch 29700/35000 | Loss: 0.0877
Epoch: 0001/0001 | Batch 30000/35000 | Loss: 0.0232
Epoch: 0001/0001 | Batch 30300/35000 | Loss: 0.0014
Epoch: 0001/0001 | Batch 30600/35000 | Loss: 0.0032
Epoch: 0001/0001 | Batch 30900/35000 | Loss: 0.0004
Epoch: 0001/0001 | Batch 31200/35000 | Loss: 0.0062
Epoch: 0001/0001 | Batch 31500/35000 | Loss: 0.0032
Epoch: 0001/0001 | Batch 31800/35000 | Loss: 0.0066
Epoch: 0001/0001 | Batch 32100/35000 | Loss: 0.0017
Epoch: 0001/0001 | Batch 32400/35000 | Loss: 0.1485
Epoch: 0001/0001 | Batch 32700/35000 | Loss: 0.0324
Epoch: 0001/0001 | Batch 33000/35000 | Loss: 0.0155
Epoch: 0001/0001 | Batch 33300/35000 | Loss: 0.0007
Epoch: 0001/0001 | Batch 33600/35000 | Loss: 0.0049
Epoch: 0001/0001 | Batch 33900/35000 | Loss: 0.1170
Epoch: 0001/0001 | Batch 34200/35000 | Loss: 0.0002
Epoch: 0001/0001 | Batch 34500/35000 | Loss: 0.4201
Epoch: 0001/0001 | Batch 34800/35000 | Loss: 0.0018
Epoch: 0001/0001 | Train acc.: 78.39% | Val acc.: 86.84%
Time elapsed 43.33 min
Test accuracy 87.91%
Beachten Sie, dass der leichte Anstieg der Genauigkeit im Vergleich zu zuvor höchstwahrscheinlich auf Zufälligkeit zurückzuführen ist.
whaosoft aiot http://143ai.com