Organizzare il Monorepo di ML con Pants

Organizzare Monorepo ML con Pants

Hai mai copiato e incollato porzioni di codice di utilità tra progetti diversi, risultando in versioni multiple dello stesso codice che vivono in repository diverse? O forse hai dovuto fare pull request a decine di progetti dopo l’aggiornamento del nome del bucket GCP in cui archivi i tuoi dati?

Situazioni come quelle descritte sopra si presentano troppo spesso nei team di ML e le loro conseguenze variano dall’annoiamento di un singolo sviluppatore all’incapacità del team di distribuire il proprio codice come richiesto. Fortunatamente, c’è una soluzione.

Immergiamoci nel mondo dei monorepo, un’architettura ampiamente adottata dalle principali aziende tecnologiche come Google, e come possono migliorare i tuoi flussi di lavoro di ML. Un monorepo offre una moltitudine di vantaggi che, nonostante alcuni svantaggi, lo rendono una scelta convincente per la gestione di ecosistemi di machine learning complessi.

Discuteremo brevemente i meriti e i demeriti dei monorepo, esamineremo perché è una scelta architetturale eccellente per i team di machine learning e daremo un’occhiata a come le BigTech lo stanno utilizzando. Infine, vedremo come sfruttare la potenza del sistema di build Pants per organizzare il tuo monorepo di machine learning in un robusto sistema di build CI/CD.

Preparati mentre ci imbarchiamo in questo viaggio per ottimizzare la gestione del tuo progetto di ML.

Cosa è un monorepo?

Monorepo di machine learning | Fonte: Autore

Un monorepo (abbreviazione di repository monolitico) è una strategia di sviluppo software in cui il codice per molti progetti è archiviato nello stesso repository. L’idea può essere ampia, come ad esempio tutto il codice aziendale scritto in una varietà di linguaggi di programmazione archiviato insieme (qualcuno ha detto Google?), o può essere limitata a un paio di progetti Python sviluppati da un piccolo team inseriti in un unico repository.

In questo post del blog, ci concentriamo sui repository che archiviano codice di machine learning.

Monorepo vs. Polyrepo

I monorepo sono in netto contrasto con l’approccio dei polyrepo, in cui ogni singolo progetto o componente ha il proprio repository separato. Molto è stato detto sugli vantaggi e svantaggi di entrambi gli approcci, e non entreremo troppo in profondità in questo dibattito. Mettiamo solo le basi sul tavolo.

L’architettura del monorepo offre i seguenti vantaggi:

Architettura monorepo | Fonte: Autore
  • Un’unica pipeline CI/CD, il che significa che non ci sono conoscenze di distribuzione nascoste tra i singoli contributori di repository diversi;
  • Commit atomici, dato che tutti i progetti risiedono nello stesso repository, gli sviluppatori possono apportare modifiche che coinvolgono più progetti ma vengono uniti come un singolo commit;
  • Facilità di condivisione di utility e modelli tra progetti;
  • Facilità di unificazione degli standard e degli approcci di codifica;
  • Migliore individuabilità del codice.

Naturalmente, non esistono pasti gratis. Dobbiamo pagare per i vantaggi elencati sopra e il prezzo si presenta sotto forma di:

  • Sfide di scalabilità: Man mano che il codice aumenta, gestire un monorepo può diventare sempre più difficile. A una scala veramente grande, avrai bisogno di strumenti e server potenti per gestire operazioni come clonare, tirare e spingere le modifiche, che possono richiedere una quantità significativa di tempo e risorse.
  • Complessità: Un monorepo può essere più complesso da gestire, soprattutto per quanto riguarda le dipendenze e la versioning. Una modifica in un componente condiviso potrebbe potenzialmente avere un impatto su molti progetti, quindi è necessaria cautela per evitare cambiamenti che possono causare problemi.
  • Visibilità e controllo degli accessi: Con tutti che lavorano nello stesso repository, può essere difficile controllare chi ha accesso a cosa. Anche se non è un vero e proprio svantaggio, potrebbe causare problemi di natura legale nei casi in cui il codice è soggetto a un NDA molto rigoroso.

La decisione se i vantaggi offerti da un monorepo valgano il prezzo da pagare deve essere determinata da ogni organizzazione o team individualmente. Tuttavia, a meno che non stia operando a una scala proibitivamente grande o stia affrontando missioni top-secret, sostenerei che – almeno per quanto riguarda la mia area di competenza, i progetti di machine learning – un monorepo sia una buona scelta architetturale nella maggior parte dei casi.

Parliamo del motivo per cui è così.

Machine learning con monorepo 

Ci sono almeno sei motivi per i quali i monorepo sono particolarmente adatti ai progetti di machine learning.

  • 1
    Integrazione del flusso di dati
  • 2
    Coerenza tra gli esperimenti
  • 3
    Semplificazione della gestione delle versioni dei modelli
  • 4
    Collaborazione interfunzionale
  • 5
    Modifiche atomiche
  • 6
    Unificazione degli standard di programmazione

Integrazione del flusso di dati

I progetti di machine learning spesso coinvolgono flussi di dati che elaborano, trasformano e alimentano i dati nel modello. Questi flussi di dati possono essere strettamente integrati con il codice di ML. Mantenere i flussi di dati e il codice di ML nello stesso repository aiuta a mantenere questa stretta integrazione e semplifica il flusso di lavoro.

Coerenza tra gli esperimenti

Lo sviluppo del machine learning comporta molte sperimentazioni. Avere tutti gli esperimenti in un monorepo garantisce una configurazione ambientale coerente e riduce il rischio di discrepanze tra diversi esperimenti dovute a versioni diverse di codice o dati.

Semplificazione della gestione delle versioni dei modelli

In un monorepo, le versioni del codice e dei modelli sono sincronizzate perché vengono controllate nello stesso repository. Questo rende più facile gestire e tracciare le versioni dei modelli, il che può essere particolarmente importante nei progetti in cui la riproducibilità del ML è fondamentale. 

Basta prendere il commit SHA in un determinato momento e si ottiene l’informazione sullo stato di tutti i modelli e i servizi.

Collaborazione interfunzionale

I progetti di machine learning spesso coinvolgono la collaborazione tra scienziati dei dati, ingegneri di ML e ingegneri del software. Un monorepo facilita questa collaborazione interfunzionale fornendo una singola fonte di verità per tutto il codice e le risorse correlate al progetto.

Modifiche atomiche

Nel contesto del ML, le prestazioni di un modello possono dipendere da vari fattori interconnessi come la preelaborazione dei dati, l’estrazione delle caratteristiche, l’architettura del modello e la post-elaborazione. Un monorepo consente modifiche atomiche: una modifica a più di questi componenti può essere eseguita come una sola modifica, garantendo che le interdipendenze siano sempre sincronizzate.

Unificazione degli standard di programmazione

Infine, i team di machine learning spesso includono membri senza una formazione in ingegneria del software. Questi matematici, statistici ed econometrici sono persone intelligenti con idee brillanti e competenze per addestrare modelli che risolvono problemi aziendali. Tuttavia, scrivere codice pulito, facile da leggere e mantenere potrebbe non essere sempre il loro punto di forza principale. 

Un monorepo aiuta automaticamente a verificare ed applicare gli standard di programmazione su tutti i progetti, garantendo non solo una qualità del codice elevata, ma aiutando anche i membri del team meno orientati all’ingegneria a imparare e crescere.

Come si fa nell’industria: famosi monorepo

Nel panorama dello sviluppo software, alcune delle più grandi e di successo aziende al mondo utilizzano monorepo. Ecco alcuni esempi notevoli.

  • Google: Google da tempo è un sostenitore convinto dell’approccio monorepo. L’intera base di codice, stimata contenere 2 miliardi di righe di codice, è contenuta in un singolo repository massiccio. Hanno persino pubblicato un articolo a riguardo.
  • Meta: Meta utilizza anche un monorepo per la loro vasta base di codice. Hanno creato un sistema di controllo delle versioni chiamato “Mercurial” per gestire la dimensione e la complessità del loro monorepo.
  • Twitter: Twitter gestisce il loro monorepo da molto tempo utilizzando Pants, il sistema di costruzione di cui parleremo successivamente!

Molte altre aziende come Microsoft, Uber, Airbnb e Stripe utilizzano l’approccio monorepo almeno per alcune parti dei loro codebase.

Basta con la teoria! Diamo un’occhiata a come effettivamente costruire un monorepo di machine learning. Perché semplicemente unire quello che un tempo erano repository separati in una cartella non fa il lavoro.

Come configurare un monorepo di ML con Python?

In questa sezione, baseremo la nostra discussione su un repository di machine learning di esempio che ho creato per questo articolo. Si tratta di un monorepo semplice che contiene solo un progetto o modulo: un classificatore di cifre scritte a mano chiamato mnist, dal famoso dataset che utilizza. 

Tutto ciò che devi sapere al momento è che nella root del monorepo c’è una directory chiamata mnist e al suo interno ci sono alcuni codici Python per addestrare il modello, i relativi test di unità e un Dockerfile per eseguire l’addestramento in un container.

Utilizzeremo questo piccolo esempio per mantenere le cose semplici, ma in un monorepo più grande, mnist sarebbe solo una delle tante cartelle di progetto nella radice del repo, ognuna delle quali conterrà codice sorgente, test, dockerfile e file di requisiti almeno.

Sistema di compilazione: perché ne hai bisogno e come sceglierlo?

Perché?

Pensa a tutte le azioni, diverse dalla scrittura del codice, che i diversi team che sviluppano progetti diversi all’interno del monorepo svolgono come parte del loro flusso di lavoro di sviluppo. Eseguono i linters sul loro codice per garantire il rispetto degli standard di stile, eseguono test unitari, compilano artefatti come container Docker e pacchetti Python, li caricano su repository di artefatti esterni e li distribuiscono in produzione.

Pensa ai test.

Hai apportato una modifica a una funzione di utilità che mantieni, hai eseguito i test e tutto è a posto. Ma come puoi essere sicuro che la tua modifica non sta rompendo il codice per altri team che potrebbero importare la tua utility? Dovresti eseguire anche il loro insieme di test, ovviamente.

Ma per fare ciò, devi sapere esattamente dove viene utilizzato il codice che hai modificato. Man mano che la base di codice cresce, scoprirlo manualmente non è scalabile. Naturalmente, come alternativa, puoi sempre eseguire tutti i test, ma di nuovo: questo approccio non scala molto bene.

Perché è necessario un sistema: testing | Fonte: Autore

Un altro esempio, distribuzione in produzione.

Indipendentemente dal fatto che tu distribuisca settimanalmente, giornalmente o in modo continuo, quando arriva il momento, dovresti compilare tutti i servizi nel monorepo e caricarli in produzione. Ma hey, è necessario compilarli tutti ad ogni occasione? Potrebbe richiedere molto tempo e risultare costoso su larga scala.

Alcuni progetti potrebbero non essere stati aggiornati da settimane. D’altra parte, il codice di utilità condiviso che utilizzano potrebbe aver ricevuto aggiornamenti. Come decidiamo cosa compilare? Di nuovo, si tratta di dipendenze. Idealmente, dovremmo compilare solo i servizi che sono stati influenzati dai cambiamenti recenti.

Perché è necessario un sistema: distribuzione | Fonte: Autore

Tutto questo può essere gestito con uno script shell semplice con una piccola base di codice, ma man mano che cresce e i progetti iniziano a condividere il codice, emergono sfide, molte delle quali ruotano attorno alla gestione delle dipendenze.

Scegliere il sistema giusto

Tutto quanto sopra non è più un problema se investi in un adeguato sistema di compilazione. Il compito principale di un sistema di compilazione è quello di compilare il codice. E dovrebbe farlo in modo intelligente: lo sviluppatore dovrebbe solo dover dire cosa compilare (“compila le immagini Docker influenzate dal mio ultimo commit” o “esegui solo quei test che coprono il codice che utilizza il metodo che ho aggiornato”), ma il come dovrebbe essere lasciato al sistema per capire.

Ci sono un paio di ottimi sistemi di compilazione open-source disponibili. Poiché la maggior parte del machine learning viene eseguita in Python, concentriamoci su quelli con il miglior supporto per Python. Le due scelte più popolari in questo senso sono Bazel e Pants.

Bazel è una versione open-source del sistema di compilazione interno di Google, Blaze. Pants è anche fortemente ispirato a Blaze e mira a obiettivi di progettazione tecnica simili a Bazel. Un lettore interessato troverà un buon confronto tra Pants e Bazel in questo post del blog (ma tieni presente che proviene dagli sviluppatori di Pants). La tabella in fondo a monorepo.tools offre ancora un altro confronto.

Entrambi i sistemi sono ottimi e non è mia intenzione dichiarare una soluzione “migliore” qui. Detto questo, Pants è spesso descritto come più facile da configurare, più accessibile e ben ottimizzato per Python, il che lo rende perfetto per i monorepo di machine learning.

Nella mia esperienza personale, il fattore decisivo che mi ha portato a scegliere Pants è stata la sua comunità attiva e disponibile. Ogni volta che hai domande o dubbi, basta pubblicarli sul canale Slack della comunità e un gruppo di persone di supporto ti aiuterà presto.

Presentazione di Pants

Ora è il momento di entrare nel vivo! Andremo passo dopo passo, presentando le diverse funzionalità di Pants e come implementarle. Puoi anche dare un’occhiata al repository di esempio associato qui.

Configurazione

Pants può essere installato con pip. In questo tutorial, utilizzeremo la versione stabile più recente al momento della scrittura, la 2.15.1.

pip install pantsbuild.pants==2.15.1

Pants può essere configurato tramite un file di configurazione globale chiamato pants.toml. In esso, possiamo configurare il comportamento di Pants e le impostazioni degli strumenti di supporto di cui si avvale, come pytest o mypy.

Iniziamo con un pants.toml minimo indispensabile:

[GLOBAL]

pants_version = "2.15.1"

backend_packages = [

    "pants.backend.python",

]

[source]

root_patterns = ["/"]

[python]

interpreter_constraints = ["==3.9.*"]

Nella sezione globale, definiamo la versione di Pants e i pacchetti di supporto di cui abbiamo bisogno. Questi pacchetti sono i motori di Pants che supportano diverse funzionalità. Per cominciare, includiamo solo il backend di Python.

Nella sezione source, impostiamo la radice del repository. A partire dalla versione 2.15, per assicurarci che questa impostazione venga presa in considerazione, è necessario aggiungere anche un file BUILD_ROOT vuoto nella radice del repository.

Infine, nella sezione Python, scegliamo la versione di Python da utilizzare. Pants esplorerà il nostro sistema alla ricerca di una versione che corrisponda alle condizioni specificate qui, quindi assicurati di avere questa versione installata.

Questo è tutto per iniziare! Ora diamo un’occhiata al cuore di qualsiasi sistema di compilazione: i file BUILD.

File di compilazione

I file di compilazione sono file di configurazione utilizzati per definire i target (cioè cosa compilare) e le loro dipendenze (cioè ciò di cui hanno bisogno per funzionare) in modo dichiarativo.

Puoi avere più file di compilazione a diversi livelli della struttura delle directory. Più ne hai, maggiore è il controllo granulare sulla gestione delle dipendenze. In effetti, Google ha un file di compilazione praticamente in ogni directory del loro repository.

Nel nostro esempio, utilizzeremo tre file di compilazione:

  • mnist/BUILD – nella directory del progetto, questo file di compilazione definirà i requisiti di Python per il progetto e il container Docker da compilare;
  • mnist/src/BUILD – nella directory del codice sorgente, questo file di compilazione definirà le sorgenti di Python, ovvero i file che saranno coperti da controlli specifici per Python;
  • mnist/tests/BUILD – nella directory dei test, questo file di compilazione definirà quali file verranno eseguiti con Pytest e quali dipendenze sono necessarie per l’esecuzione di questi test.

Diamo un’occhiata al mnist/src/BUILD:

python_sources(

    name="python",

    resolve="mnist",

    sources=["**/*.py"],

)

Allo stesso tempo, mnist/BUILD sarà così:

python_requirements(

    name="reqs",

    source="requirements.txt",

    resolve="mnist",

)

Le due voci nei file di compilazione sono chiamate target. Innanzitutto, abbiamo un target di sorgenti Python, che chiamiamo in modo appropriato python, anche se il nome potrebbe essere qualsiasi cosa. Definiamo le nostre sorgenti Python come tutti i file .py nella directory. Questo è relativo alla posizione del file di compilazione, ovvero: anche se avessimo file Python al di fuori della directory mnist/src, queste sorgenti catturano solo i contenuti della cartella mnist/src. C’è anche un campo di risoluzione; ne parleremo tra un attimo.

Successivamente, abbiamo il target dei requisiti di Python. Dice a Pants dove trovare i requisiti necessari per eseguire il nostro codice Python (di nuovo, relativamente alla posizione del file di compilazione, che si trova nella radice del progetto mnist in questo caso).

Questo è tutto ciò di cui abbiamo bisogno per iniziare. Per assicurarci che la definizione dei file di compilazione sia corretta, eseguiamo:

pants tailor --check update-build-files --check ::

Come previsto, otteniamo l’output “Nessuna modifica richiesta ai file di compilazione trovata.”. Bene!

Dedichiamo ancora un po’ di tempo a questo comando. In poche parole, pants tailor può creare automaticamente i file di compilazione. Tuttavia, a volte tende ad aggiungerne troppi per le proprie esigenze, motivo per cui tendo ad aggiungerli manualmente, seguiti dal comando di cui sopra che ne verifica la correttezza.

La doppia virgola alla fine è una notazione di Pants che indica di eseguire il comando sull’intero monorepo. In alternativa, avremmo potuto sostituirlo con mnist: per eseguire solo sul modulo mnist.

Dipendenze e file di blocco

Per una gestione efficiente delle dipendenze, Pants si basa sui file di blocco. I file di blocco registrano le versioni specifiche e le origini di tutte le dipendenze utilizzate da ciascun progetto. Questo include sia le dipendenze dirette che le dipendenze trasitive.

Catturando queste informazioni, i file di blocco assicurano che vengano utilizzate le stesse versioni delle dipendenze in modo coerente in diversi ambienti e compilazioni. In altre parole, fungono da snapshot del grafo delle dipendenze, garantendo riproducibilità e coerenza tra le compilazioni.

Per generare un file di blocco per il nostro modulo mnist, abbiamo bisogno dell’aggiunta seguente a pants.toml:

[python]
interpreter_constraints = ["==3.9.*"]
enable_resolves = true
default_resolve = "mnist"

[python.resolves]
mnist = "mnist/mnist.lock"

Abilitiamo le risoluzioni (termine di Pants per gli ambienti dei file di blocco) e ne definiamo una per mnist passando un percorso di file. Lo scegliamo anche come predefinito. Questa è la risoluzione che abbiamo passato alle sorgenti Python e all’obiettivo dei requisiti Python prima: è così che sanno quali dipendenze sono necessarie. Ora possiamo eseguire:

pants generate-lockfiles

per ottenere:

Completato: Genera file di blocco per mnist
Scritto file di blocco per la risoluzione `mnist` in mnist/mnist.lock

Questo ha creato un file in mnist/mnist.lock. Questo file dovrebbe essere controllato con git se si intende utilizzare Pants per il proprio CI/CD remoto. E naturalmente, deve essere aggiornato ogni volta che si aggiorna il file requirements.txt.

Con più progetti nel monorepo, è meglio generare i file di blocco selettivamente per il progetto che ne ha bisogno, ad esempio pants generate-lockfiles mnist: .

Questo è tutto per la configurazione! Ora usiamo Pants per fare qualcosa di utile per noi.

Unificazione dello stile del codice con Pants

Pants supporta nativamente diversi linters Python e strumenti di formattazione del codice come Black, yapf, Docformatter, Autoflake, Flake8, isort, Pyupgrade o Bandit. Sono tutti utilizzati allo stesso modo; nel nostro esempio, implementiamo Black e Docformatter.

Per farlo, aggiungiamo due backend appropriati a pants.toml:

[GLOBAL]
pants_version = "2.15.1"
colors = true
backend_packages = [
    "pants.backend.python",
    "pants.backend.python.lint.docformatter",
    "pants.backend.python.lint.black",
]

Potremmo configurare entrambi gli strumenti se volessimo aggiungendo sezioni aggiuntive nel file toml, ma per ora atteniamoci alle impostazioni predefinite.

Per utilizzare i formattatori, dobbiamo eseguire ciò che viene chiamato un obiettivo di Pants. In questo caso, due obiettivi sono rilevanti.

Prima di tutto, l’obiettivo lint eseguirà entrambi gli strumenti (nell’ordine in cui sono elencati nei pacchetti di backend, quindi Docformatter prima, Black secondo) in modalità di controllo.

pants lint ::

Completato: Formattazione con docformatter - docformatter non ha apportato modifiche.
Completato: Formattazione con Black - black non ha apportato modifiche.

✓ black riuscito.
✓ docformatter riuscito.

Sembra che il nostro codice rispetti gli standard di entrambi i formattatori! Tuttavia, se non fosse così, potremmo eseguire l’obiettivo fmt (abbreviazione di “format”) che adatta il codice in modo appropriato:

pants fmt ::

In pratica, potresti voler utilizzare più di questi due formattatori. In questo caso, potresti dover aggiornare la configurazione di ciascun formattatore per garantire che sia compatibile con gli altri. Ad esempio, se stai usando Black con la sua configurazione predefinita come abbiamo fatto qui, si aspetterà che le righe di codice non superino gli 88 caratteri.

Ma se poi vuoi aggiungere isort per ordinare automaticamente le tue importazioni, entreranno in conflitto: isort tronca le righe dopo 79 caratteri. Per rendere isort compatibile con Black, dovresti includere la seguente sezione nel file toml:

[isort]
args = [
    "-l=88",
 ]

Tutti i formattatori possono essere configurati allo stesso modo in pants.toml passando gli argomenti al loro strumento sottostante.

Testing con Pants

Facciamo alcune prove! Per farlo, ci servono due passaggi.

Prima di tutto, aggiungiamo le sezioni appropriate a pants.toml:

[test]
output = "all"
report = false
use_coverage = true

[coverage-py]
global_report = true

[pytest]
args = ["-vv", "-s", "-W ignore::DeprecationWarning", "--no-header"]

Queste impostazioni assicurano che durante l’esecuzione dei test venga prodotto un rapporto di copertura dei test. Passiamo anche un paio di opzioni personalizzate a pytest per adattare la sua uscita.

Successivamente, dobbiamo tornare al file mnist/tests/BUILD e aggiungere una destinazione di test Python:

python_tests(
    name="tests",
    resolve="mnist",
    sources=["test_*.py"],
)

La chiamiamo tests e specifichiamo la risoluzione (cioè il file di blocco) da utilizzare. Le sorgenti sono le posizioni in cui pytest sarà autorizzato a cercare i test da eseguire; qui, passiamo esplicitamente tutti i file .py con prefisso “test_”.

Ora possiamo eseguire:

pants test ::

per ottenere:


✓ mnist/tests/test_data.py:../tests ha avuto successo in 3,83s.
✓ mnist/tests/test_model.py:../tests ha avuto successo in 2,26s.

Nome                               Istruzioni   Mancanti  Copertura
------------------------------------------------------
__global_coverage__/no-op-exe.py       0      0   100%
mnist/src/data.py                     14      0   100%
mnist/src/model.py                    15      0   100%
mnist/tests/test_data.py              21      1    95%
mnist/tests/test_model.py             20      1    95%
------------------------------------------------------
TOTALE                                 70      2    97%

Come puoi vedere, ci sono voluti circa tre secondi per eseguire questa suite di test. Ora, se lo eseguiamo di nuovo, otterremo i risultati immediatamente:

✓ mnist/tests/test_data.py:../tests ha avuto successo in 3,83s (memorizzato).
✓ mnist/tests/test_model.py:../tests ha avuto successo in 2,26s (memorizzato).

Notare come Pants ci dica che questi risultati sono memorizzati nella cache. Dal momento che non sono state apportate modifiche ai test, al codice testato o ai requisiti, non è necessario eseguire effettivamente i test nuovamente: i loro risultati sono garantiti essere gli stessi, quindi vengono semplicemente serviti dalla cache.

Verifica della tipizzazione statica con Pants

Aggiungiamo un altro controllo sulla qualità del codice. Pants consente di utilizzare mypy per verificare la tipizzazione statica in Python. Tutto ciò che dobbiamo fare è aggiungere il backend mypy in pants.toml: “pants.backend.python.typecheck.mypy”.

Potresti voler configurare anche mypy per rendere la sua uscita più leggibile e informativa, aggiungendo anche la seguente sezione di configurazione:

[mypy]
args = [
    "--ignore-missing-imports",
    "--local-partial-types",
    "--pretty",
    "--color-output",
    "--error-summary",
    "--show-error-codes",
    "--show-error-context",
]

Con questo, possiamo eseguire pants check :: per ottenere:

Completato: Typecheck usando MyPy - mypy - mypy ha avuto successo.
Successo: nessun problema trovato in 6 file di origine

✓ mypy ha avuto successo.

Spedizione di modelli di apprendimento automatico con Pants

Parliamo di spedizione. La maggior parte dei progetti di apprendimento automatico coinvolge uno o più container Docker, ad esempio per l’elaborazione dei dati di addestramento, l’addestramento di un modello o il servizio tramite un’API utilizzando Flask o FastAPI. Nel nostro progetto di prova, abbiamo anche un container per l’addestramento del modello.

Pants supporta la creazione e l’invio automatici di immagini Docker. Vediamo come funziona.

Prima di tutto, aggiungiamo il backend docker in pants.toml: pants.backend.docker. Configureremo anche il nostro Docker, passando un certo numero di variabili d’ambiente e un argomento di compilazione che ci sarà utile tra un attimo:

[docker]

build_args = ["SHORT_SHA"]

env_vars = ["DOCKER_CONFIG=%(env.HOME)s/.docker", "HOME", "USER", "PATH"]

Ora, nel file mnist/BUILD, aggiungeremo altre due destinazioni: una destinazione per i file e una destinazione per l'immagine Docker.

files(

    name="module_files",

    sources=["**/*"],

)

docker_image(

    name="train_mnist",

    dependencies=["mnist:module_files"],

    registries=["docker.io"],

    repository="michaloleszak/mnist",

    image_tags=["latest", "{build_args.SHORT_SHA}"],

)

Chiamiamo il target Docker “train_mnist”. Come dipendenza, dobbiamo passargli l’elenco dei file da includere nel container. Il modo più conveniente per farlo è definire questo elenco come un target di file separato. Qui, includiamo semplicemente tutti i file nel progetto mnist in un target chiamato module_files e lo passiamo come dipendenza al target dell’immagine Docker.

Naturalmente, se sai che è necessario solo un sottoinsieme di file per il container, è una buona idea passare solo quelli come dipendenza. Questo è essenziale perché queste dipendenze vengono utilizzate da Pants per dedurre se un container è stato influenzato da una modifica e ha bisogno di essere ricostruito. Qui, con module_files che include tutti i file, se qualsiasi file nella cartella mnist cambia (anche solo un readme!), Pants vedrà l’immagine Docker train_mnist come influenzata da questa modifica.

Infine, possiamo anche impostare il registro esterno e il repository a cui l’immagine può essere spinta, e i tag con cui verrà spinta: qui, spingerò l’immagine nel mio repo personale di Dockerhub, sempre con due tag: “latest” e il breve SHA del commit che verrà passato come argomento di build.

In questo modo possiamo creare un’immagine. Solo un’altra cosa: poiché Pants lavora nei suoi ambienti isolati, non può leggere le variabili d’ambiente dell’host. Pertanto, per creare o spingere l’immagine che richiede la variabile SHORT_SHA, è necessario passarla insieme al comando Pants.

Possiamo creare l’immagine in questo modo:

SHORT_SHA=$(git rev-parse --short HEAD) pants package mnist:train_mnist 

per ottenere:

Completato: Costruzione dell'immagine Docker docker.io/michaloleszak/mnist:latest +1 tag aggiuntivo.
Immagini Docker create: 
  * docker.io/michaloleszak/mnist:latest
  * docker.io/michaloleszak/mnist:0185754

Un rapido controllo rivela che le immagini sono state effettivamente create:

docker images 


REPOSITORY            TAG       IMAGE ID       CREATED              SIZE
michaloleszak/mnist   0185754   d86dca9fb037   Un minuto fa         3.71GB
michaloleszak/mnist   latest    d86dca9fb037   Un minuto fa         3.71GB

Possiamo anche creare e spingere le immagini in un’unica operazione utilizzando Pants. Tutto ciò che serve è sostituire il comando package con il comando publish.

SHORT_SHA=$(git rev-parse --short HEAD) pants publish mnist:train_mnist 

In questo modo vengono create le immagini e spinte nel mio Dockerhub, dove sono effettivamente arrivate.

Pants in CI/CD

Gli stessi comandi che abbiamo appena eseguito manualmente in locale possono essere eseguiti come parte di un pipeline CI/CD. Puoi eseguirli tramite servizi come GitHub Actions o Google CloudBuild, ad esempio come controllo PR prima che un ramo di funzionalità sia consentito di essere unito al ramo principale, o dopo la fusione, per verificarne la validità e creare e spingere i container.

Nel nostro repository di esempio, ho implementato un hook di commit pre-push che esegue i comandi Pants su git push e lo lascia passare solo se tutti superano. In esso, stiamo eseguendo i seguenti comandi:

pants tailor --check update-build-files --check ::
pants lint ::
pants --changed-since=main --changed-dependees=transitive check
pants test ::

Puoi vedere alcune nuove opzioni per pants check, che è il controllo di tipo con mypy. Assicurano che il controllo venga eseguito solo sui file che sono cambiati rispetto al ramo principale e alle loro dipendenze transitive. Questo è utile poiché mypy tende a richiedere del tempo per eseguire. Limitare il suo ambito a ciò che è effettivamente necessario accelera il processo.

Come apparirebbe una build e uno spostamento Docker in una pipeline CI/CD? In qualche modo così:

pants --changed-since=HEAD^ --changed-dependees=transitive --filter-target-type=docker_image publish

Usiamo il comando publish come prima, ma con tre argomenti aggiuntivi:

  • –changed-since=HEAD^ e –changed-dependees=transitive si assicurano che vengano costruiti solo i container influenzati dalle modifiche rispetto al commit precedente; questo è utile per l’esecuzione sul ramo principale dopo la fusione.
  • –filter-target-type=docker_image si assicura che Pants faccia solo la build e lo spostamento di Docker; questo perché il comando pants publish può fare riferimento a target diversi da Docker: ad esempio, può essere utilizzato per pubblicare chart di Helm in registri OCI.

Lo stesso vale per il pacchetto dei pantaloni: oltre alla creazione di immagini docker, può anche creare un pacchetto Python; per questo motivo, è una buona pratica passare l’opzione –filter-target-type.

Conclusioni

I monorepo sono spesso una ottima scelta architetturale per i team di machine learning. Gestirli su larga scala, tuttavia, richiede un investimento in un adeguato sistema di compilazione. Uno di questi sistemi è Pants: è facile da configurare e utilizzare e offre il supporto nativo per molte funzionalità Python e Docker che i team di machine learning spesso utilizzano.

Inoltre, è un progetto open-source con una comunità ampia e disponibile. Spero che dopo aver letto questo articolo tu decida di provarlo. Anche se al momento non hai un repository monolitico, Pants può comunque semplificare e facilitare molti aspetti del tuo lavoro quotidiano!

Riferimenti

  • Documentazione di Pants: https://www.pantsbuild.org/
  • Articolo del blog Pants vs. Bazel: https://blog.pantsbuild.org/pants-vs-bazel/
  • monorepo.tools: https://monorepo.tools/