Deployare 🤗 ViT su Kubernetes con TF Serving

Deploy 🤗 ViT su Kubernetes con TF Serving

Nel post precedente, abbiamo mostrato come distribuire un modello Vision Transformer (ViT) di 🤗 Transformers localmente con TensorFlow Serving. Abbiamo coperto argomenti come l’elaborazione dell’embedding e le operazioni di preprocessing e postprocessing all’interno del modello Vision Transformer, la gestione delle richieste gRPC e altro ancora!

Mentre le distribuzioni locali sono un ottimo punto di partenza per costruire qualcosa di utile, è necessario eseguire distribuzioni che possano servire molti utenti in progetti reali. In questo post, imparerai come scalare la distribuzione locale del post precedente con Docker e Kubernetes. Pertanto, assumiamo una certa familiarità con Docker e Kubernetes.

Questo post si basa sul post precedente, quindi consigliamo vivamente di leggerlo prima. Puoi trovare tutto il codice discusso in questo repository.

Il flusso di lavoro di base per scalare una distribuzione come la nostra include i seguenti passaggi:

  • Containerizzare la logica dell’applicazione: La logica dell’applicazione coinvolge un modello servito che può gestire le richieste e restituire le previsioni. Per la containerizzazione, Docker è lo standard del settore.

  • Deployare il container Docker: Hai diverse opzioni qui. L’opzione più utilizzata è distribuire il container Docker su un cluster Kubernetes. Kubernetes fornisce numerose funzionalità di distribuzione (ad esempio, autoscaling e sicurezza). Puoi utilizzare una soluzione come Minikube per gestire i cluster Kubernetes localmente o una soluzione serverless come Elastic Kubernetes Service (EKS).

Potresti chiederti perché utilizzare una configurazione esplicita come questa nell’era di Sagemaker, Vertex AI, che fornisce funzionalità specifiche per la distribuzione di ML fin dall’inizio. È giusto pensarci.

Il flusso di lavoro sopra è ampiamente adottato nel settore e molte organizzazioni ne beneficiano. È già stato testato sul campo per molti anni. Ti consente inoltre di avere un controllo più granulare delle tue distribuzioni mentre astrae le parti non banali.

Questo post utilizza Google Kubernetes Engine (GKE) per fornire e gestire un cluster Kubernetes. Assumiamo che tu abbia già un progetto GCP abilitato alla fatturazione se stai usando GKE. Inoltre, tieni presente che dovresti configurare l’utilità gcloud per eseguire il deployment su GKE. Ma i concetti discussi in questo post si applicano anche se decidi di utilizzare Minikube.

Nota: I frammenti di codice mostrati in questo post possono essere eseguiti su un terminale Unix purché tu abbia configurato l’utilità gcloud insieme a Docker e kubectl. Ulteriori istruzioni sono disponibili nel repository allegato.

Il modello di servizio può gestire input immagine grezzi come byte ed è in grado di elaborare il preprocessing e il postprocessing.

In questa sezione, vedrai come containerizzare quel modello utilizzando l’immagine di base TensorFlow Serving. TensorFlow Serving consuma modelli nel formato SavedModel. Ricorda come hai ottenuto un tale SavedModel nel post precedente. Assumiamo che tu abbia il SavedModel compresso nel formato tar.gz. Puoi scaricarlo da qui nel caso. Quindi il SavedModel dovrebbe essere posizionato nella struttura di directory speciale di <MODEL_NAME>/<VERSION>/<SavedModel>. In questo modo TensorFlow Serving gestisce contemporaneamente più distribuzioni di modelli versionati diversi.

Preparazione dell’immagine Docker

Lo script shell di seguito posiziona il SavedModel in hf-vit/1 sotto la directory padre models. Copierai tutto ciò che è contenuto al suo interno durante la preparazione dell’immagine Docker. In questo esempio c’è solo un modello, ma questo è un approccio più generalizzabile.

$ MODEL_TAR=model.tar.gz
$ MODEL_NAME=hf-vit
$ MODEL_VERSION=1
$ MODEL_PATH=models/$MODEL_NAME/$MODEL_VERSION

$ mkdir -p $MODEL_PATH
$ tar -xvf $MODEL_TAR --directory $MODEL_PATH

Sotto, mostriamo come è strutturata la directory models nel nostro caso:

$ find /models
/models
/models/hf-vit
/models/hf-vit/1
/models/hf-vit/1/keras_metadata.pb
/models/hf-vit/1/variables
/models/hf-vit/1/variables/variables.index
/models/hf-vit/1/variables/variables.data-00000-of-00001
/models/hf-vit/1/assets
/models/hf-vit/1/saved_model.pb

L’immagine personalizzata di TensorFlow Serving dovrebbe essere costruita sulla base di quella di base. Ci sono vari approcci per farlo, ma lo faremo eseguendo un container Docker come illustrato nel documento ufficiale. Iniziamo eseguendo l’immagine tensorflow/serving in modalità di background, quindi l’intera directory models viene copiata nel container in esecuzione come mostrato di seguito.

$ docker run -d --name serving_base tensorflow/serving
$ docker cp models/ serving_base:/models/

Abbiamo utilizzato l’immagine Docker ufficiale di TensorFlow Serving come base, ma è possibile utilizzare anche quelle che hai costruito dal codice sorgente.

Nota: TensorFlow Serving trae vantaggio dalle ottimizzazioni hardware che sfruttano set di istruzioni come AVX512. Questi set di istruzioni possono velocizzare l’inferenza dei modelli di deep learning. Pertanto, se conosci l’hardware su cui il modello verrà distribuito, è spesso vantaggioso ottenere una versione ottimizzata dell’immagine di TensorFlow Serving e utilizzarla in tutto.

Ora che il container in esecuzione ha tutti i file necessari nella struttura delle directory appropriata, è necessario creare una nuova immagine Docker che includa queste modifiche. Ciò può essere fatto con il comando docker commit di seguito, e avrai una nuova immagine Docker chiamata $NEW_IMAGE. Una cosa importante da notare è che è necessario impostare la variabile d’ambiente MODEL_NAME sul nome del modello, che in questo caso è hf-vit. Questo dice a TensorFlow Serving quale modello distribuire.

$ NEW_IMAGE=tfserving:$MODEL_NAME

$ docker commit \ 
    --change "ENV MODEL_NAME $MODEL_NAME" \ 
    serving_base $NEW_IMAGE

Esecuzione dell’immagine Docker in locale

Infine, è possibile eseguire l’immagine Docker appena creata in locale per verificare che funzioni correttamente. Di seguito viene mostrato l’output del comando docker run. Poiché l’output è verboso, l’abbiamo ridotto per concentrarci sui punti importanti. Inoltre, vale la pena notare che apre le porte 8500 e 8501 per le interfacce gRPC e HTTP/REST, rispettivamente.

$ docker run -p 8500:8500 -p 8501:8501 -t $NEW_IMAGE &


---------OUTPUT---------
(Re-)adding model: hf-vit
Successfully reserved resources to load servable {name: hf-vit version: 1}
Approving load for servable version {name: hf-vit version: 1}
Loading servable version {name: hf-vit version: 1}
Reading SavedModel from: /models/hf-vit/1
Reading SavedModel debug info (if present) from: /models/hf-vit/1
Successfully loaded servable version {name: hf-vit version: 1}
Running gRPC ModelServer at 0.0.0.0:8500 ...
Exporting HTTP/REST API at:localhost:8501 ...

Caricamento dell’immagine Docker

Il passaggio finale consiste nel caricare l’immagine Docker in un repository di immagini. Utilizzerai Google Container Registry (GCR) per questo scopo. Le seguenti righe di codice possono fare questo per te:

$ GCP_PROJECT_ID=<GCP_PROJECT_ID>
$ GCP_IMAGE=gcr.io/$GCP_PROJECT_ID/$NEW_IMAGE

$ gcloud auth configure-docker
$ docker tag $NEW_IMAGE $GCP_IMAGE
$ docker push $GCP_IMAGE

Dato che stiamo usando GCR, è necessario aggiungere il prefisso (nota anche gli altri formati) al tag dell’immagine Docker con gcr.io/<GCP_PROJECT_ID>. Con l’immagine Docker preparata e caricata su GCR, puoi procedere con il suo deployment su un cluster Kubernetes.

Il deployment su un cluster Kubernetes richiede quanto segue:

  • Provisionare un cluster Kubernetes, eseguito con Google Kubernetes Engine (GKE) in questo post. Tuttavia, puoi anche utilizzare altre piattaforme e strumenti come EKS o Minikube.

  • Connettersi al cluster Kubernetes per eseguire un deployment.

  • Scrivere i manifesti YAML.

  • Eseguire il deployment con i manifesti utilizzando lo strumento di utilità kubectl.

Analizziamo ognuno di questi passaggi.

Provisioning di un cluster Kubernetes su GKE

Puoi usare uno script shell come questo (disponibile qui):

$ GKE_CLUSTER_NAME=tfs-cluster
$ GKE_CLUSTER_ZONE=us-central1-a
$ NUM_NODES=2
$ MACHINE_TYPE=n1-standard-8

$ gcloud container clusters create $GKE_CLUSTER_NAME \
    --zone=$GKE_CLUSTER_ZONE \
    --machine-type=$MACHINE_TYPE \
    --num-nodes=$NUM_NODES

GCP offre una varietà di tipi di macchine per configurare il deployment come desideri. Ti incoraggiamo a fare riferimento alla documentazione per saperne di più.

Una volta che il cluster è stato creato, devi connetterti ad esso per eseguire il deployment. Poiché qui viene utilizzato GKE, è necessario autenticarsi. Puoi usare uno script shell come questo per fare entrambe queste operazioni:

$ GCP_PROJECT_ID=<ID_PROGETTO_GCP>

$ export USE_GKE_GCLOUD_AUTH_PLUGIN=True

$ gcloud container clusters get-credentials $GKE_CLUSTER_NAME \
    --zone $GKE_CLUSTER_ZONE \
    --project $GCP_PROJECT_ID

Il comando gcloud container clusters get-credentials si occupa sia di connettersi al cluster che di autenticarsi. Una volta fatto ciò, sei pronto per scrivere i manifesti.

Scrittura dei manifesti di Kubernetes

I manifesti di Kubernetes sono scritti in file YAML. Sebbene sia possibile utilizzare un singolo file manifesto per eseguire il deployment, creare file manifest separati è spesso vantaggioso per delegare la separazione delle responsabilità. È comune utilizzare tre file manifest per raggiungere questo obiettivo:

  • deployment.yaml definisce lo stato desiderato del Deployment fornendo il nome dell’immagine Docker, gli argomenti aggiuntivi durante l’esecuzione dell’immagine Docker, le porte da aprire per gli accessi esterni e i limiti delle risorse.

  • service.yaml definisce le connessioni tra i clienti esterni e i Pod all’interno del cluster Kubernetes.

  • hpa.yaml definisce le regole per scalare verso l’alto e verso il basso il numero di Pod che compongono il Deployment, ad esempio la percentuale di utilizzo della CPU.

Puoi trovare i manifesti pertinenti per questo post qui . Di seguito, presentiamo una panoramica grafica di come vengono utilizzati questi manifesti.

Successivamente, analizziamo le parti importanti di ciascuno di questi manifesti.

deployment.yaml :

apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: tfs-server
  name: tfs-server
...
spec:
  containers:
  - image: gcr.io/$GCP_PROJECT_ID/tfserving-hf-vit:latest
    name: tfs-k8s
    imagePullPolicy: Always
    args: ["--tensorflow_inter_op_parallelism=2", 
           "--tensorflow_intra_op_parallelism=8"] 
    ports:
    - containerPort: 8500
      name: grpc
    - containerPort: 8501
      name: restapi
    resources:
      limits:
        cpu: 800m
      requests:
        cpu: 800m
...

Puoi configurare i nomi come tfs-server, tfs-k8s nel modo che preferisci. Sotto containers, specifica l’URI dell’immagine Docker che il deployment utilizzerà. L’utilizzo delle risorse correnti viene monitorato impostando i limiti consentiti delle resources per il container. Ciò consente all’Autoscaler di Pod Orizzontale (discusso in seguito) di decidere se aumentare o diminuire il numero di container. requests.cpu è la quantità minima di risorse CPU necessarie per far funzionare correttamente il container ed è impostata dagli operatori. Qui 800m significa l’80% della risorsa CPU totale. Quindi, l’Autoscaler di Pod Orizzontale monitora l’utilizzo medio della CPU come somma di requests.cpu in tutti i Pod per prendere decisioni di scalabilità.

Oltre alla configurazione specifica di Kubernetes, puoi specificare opzioni specifiche di TensorFlow Serving in args. In questo caso, ne hai due:

  • tensorflow_inter_op_parallelism, che imposta il numero di thread per eseguire in parallelo operazioni indipendenti. Il valore consigliato per questo parametro è 2.

  • tensorflow_intra_op_parallelism, che imposta il numero di thread per eseguire in parallelo operazioni individuali. Il valore consigliato è il numero di core fisici della CPU del deployment.

Puoi saperne di più su queste opzioni (e altre) e suggerimenti su come ottimizzarle per la distribuzione da qui e da qui .

service.yaml :

apiVersion: v1
kind: Service
metadata:
  labels:
    app: tfs-server
  name: tfs-server
spec:
  ports:
  - port: 8500
    protocol: TCP
    targetPort: 8500
    name: tf-serving-grpc
  - port: 8501
    protocol: TCP
    targetPort: 8501
    name: tf-serving-restapi
  selector:
    app: tfs-server
  type: LoadBalancer

Abbiamo impostato il tipo di servizio su ‘LoadBalancer’ in modo che gli endpoint siano esposti esternamente al cluster Kubernetes. Seleziona il Deployment ‘tfs-server’ per stabilire connessioni con client esterni tramite le porte specificate. Apriamo due porte, ‘8500’ e ‘8501’, rispettivamente per le connessioni gRPC e HTTP/REST.

hpa.yaml :

apiVersion: autoscaling/v1
kind: HorizontalPodAutoscaler
metadata:
 name: tfs-server

spec:
 scaleTargetRef:
   apiVersion: apps/v1
   kind: Deployment
   name: tfs-server
 minReplicas: 1
 maxReplicas: 3
 targetCPUUtilizationPercentage: 80

HPA sta per H orizontal P od A utoscaler. Imposta criteri per decidere quando scalare il numero di Pods nel Deployment di destinazione. Puoi saperne di più sull’algoritmo di autoscaling utilizzato internamente da Kubernetes qui .

Qui specifici come Kubernetes dovrebbe gestire l’autoscaling. In particolare, definisci il limite di repliche entro cui eseguire l’autoscaling – minReplicas e maxReplicas – e l’utilizzo target della CPU. targetCPUUtilizationPercentage è una metrica importante per l’autoscaling. Il seguente thread riassume bene cosa significa (tratto da qui ):

L’utilizzo della CPU è l’utilizzo medio della CPU di tutti i Pods in un deployment nell’ultimo minuto diviso dalla CPU richiesta di questo deployment. Se la media dell’utilizzo della CPU dei Pods è superiore al target che hai definito, le repliche verranno adattate.

Ricorda di specificare resources nel manifesto del deployment. Specificando le resources, il controllo Kubernetes inizia a monitorare le metriche, quindi il targetCPUUtilization funziona. Altrimenti, HPA non conosce lo stato corrente del Deployment.

Puoi sperimentare e impostare questi numeri in base alle tue esigenze. Tieni presente, però, che l’autoscaling dipenderà dalla quota disponibile su GCP poiché GKE utilizza internamente Google Compute Engine per gestire queste risorse.

Eseguire la distribuzione

Una volta pronti i manifest, puoi applicarli al cluster Kubernetes attualmente connesso con il comando kubectl apply.

$ kubectl apply -f deployment.yaml
$ kubectl apply -f service.yaml
$ kubectl apply -f hpa.yaml

Anche se è possibile utilizzare kubectl per applicare ciascuno dei manifest per eseguire la distribuzione, può diventare complicato se hai molti manifest diversi. In questo caso, un’utilità come Kustomize può essere utile. Basta definire un’altra specifica chiamata kustomization.yaml in questo modo:

commonLabels:
  app: tfs-server
resources:
- deployment.yaml
- hpa.yaml
- service.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

Poi è sufficiente una riga per eseguire effettivamente la distribuzione:

$ kustomize build . | kubectl apply -f -

Istruzioni complete sono disponibili qui . Una volta eseguita la distribuzione, è possibile recuperare l’indirizzo IP dell’endpoint in questo modo:

$ kubectl rollout status deployment/tfs-server
$ kubectl get svc tfs-server --watch

---------OUTPUT---------
NAME        TYPE          CLUSTER-IP   EXTERNAL-IP  PORT(S)                        AGE
tfs-server  LoadBalancer  xxxxxxxxxx   xxxxxxxxxx   8500:30869/TCP,8501:31469/TCP  xxx

Annota l’indirizzo IP esterno quando diventa disponibile.

E questo riassume tutti i passaggi necessari per distribuire il tuo modello su Kubernetes! Kubernetes fornisce elegantemente astrazioni per elementi complessi come l’autoscaling e la gestione dei cluster, consentendoti di concentrarti sugli aspetti cruciali a cui dovresti prestare attenzione durante la distribuzione di un modello. Questi includono l’utilizzo delle risorse, la sicurezza (che non abbiamo trattato qui), le stelle polari delle prestazioni come la latenza, ecc.

Dato che hai ottenuto un indirizzo IP esterno per il punto di accesso, puoi utilizzare la seguente lista per testarlo:

import tensorflow as tf 
import json
import base64

image_path = tf.keras.utils.get_file(
    "image.jpg", "http://images.cocodataset.org/val2017/000000039769.jpg"
)
bytes_inputs = tf.io.read_file(image_path)
b64str = base64.urlsafe_b64encode(bytes_inputs.numpy()).decode("utf-8")
data = json.dumps(
    {"signature_name": "serving_default", "instances": [b64str]}
)

json_response = requests.post(
    "http://<ENDPOINT-IP>:8501/v1/models/hf-vit:predict", 
    headers={"content-type": "application/json"}, 
    data=data
)
print(json.loads(json_response.text))

---------OUTPUT---------
{'predictions': [{'label': 'Gatto egiziano', 'confidence': 0.896659195}]}

Se sei interessato a sapere come si comporterebbe questa distribuzione se dovesse gestire più traffico, ti consigliamo di leggere questo articolo . Consulta il repository corrispondente per saperne di più su come eseguire test di carico con Locust e visualizzare i risultati.

TensorFlow Serving offre varie opzioni per personalizzare la distribuzione in base al caso d’uso dell’applicazione. Di seguito, discutiamo brevemente alcune di esse.

enable_batching abilita la capacità di inferenza batch che raccoglie le richieste in arrivo in un determinato intervallo di tempo, le raggruppa in un batch, esegue una inferenza batch e restituisce i risultati di ciascuna richiesta ai client appropriati. TensorFlow Serving fornisce un ricco insieme di opzioni configurabili (come max_batch_size, num_batch_threads) per adattare le tue esigenze di distribuzione. Puoi saperne di più su di esse qui . Il batching è particolarmente vantaggioso per applicazioni in cui non è necessario ottenere previsioni da un modello istantaneamente. In questi casi, di solito si raccolgono insieme più campioni per la previsione in batch e quindi si inviano tali batch per la previsione. Per nostra fortuna, TensorFlow Serving può configurare tutto questo automaticamente quando abilitiamo le sue capacità di batching.

enable_model_warmup riscalda alcuni dei componenti di TensorFlow che vengono istanziati in modo pigro con dati di input fittizi. In questo modo, puoi assicurarti che tutto sia correttamente caricato e che non ci siano ritardi durante il tempo effettivo del servizio.

In questo post e nel repository associato , hai imparato a distribuire il modello Vision Transformer di 🤗 Transformers su un cluster Kubernetes. Se lo stai facendo per la prima volta, i passaggi potrebbero sembrare un po’ intimidatori, ma una volta compresi, diventeranno presto un componente essenziale del tuo set di strumenti. Se eri già familiare con questo flusso di lavoro, speriamo che questo post ti sia comunque stato utile.

Abbiamo applicato lo stesso flusso di lavoro di distribuzione per una versione ottimizzata ONNX del medesimo modello Vision Transformer. Per ulteriori dettagli, consulta questo link . I modelli ottimizzati ONNX sono particolarmente vantaggiosi se si utilizzano CPU x86 per la distribuzione.

Nel prossimo post, ti mostreremo come eseguire queste distribuzioni con significativamente meno codice utilizzando Vertex AI, più o meno come model.deploy(autoscaling_config=...) e boom! Speriamo che tu sia altrettanto entusiasta di noi.

Grazie al team del programma ML Developer Relations di Google, che ci ha fornito crediti GCP per condurre gli esperimenti.