In che modo BLAS ottiene prestazioni così estreme?


108

Per curiosità ho deciso di confrontare la mia funzione di moltiplicazione della matrice rispetto all'implementazione BLAS ... Sono stato a dir poco sorpreso del risultato:

Implementazione personalizzata, 10 prove di moltiplicazione di matrici 1000x1000:

Took: 15.76542 seconds.

Implementazione BLAS, 10 prove di moltiplicazione di matrici 1000x1000:

Took: 1.32432 seconds.

Questo utilizza numeri in virgola mobile a precisione singola.

La mia implementazione:

template<class ValT>
void mmult(const ValT* A, int ADim1, int ADim2, const ValT* B, int BDim1, int BDim2, ValT* C)
{
    if ( ADim2!=BDim1 )
        throw std::runtime_error("Error sizes off");

    memset((void*)C,0,sizeof(ValT)*ADim1*BDim2);
    int cc2,cc1,cr1;
    for ( cc2=0 ; cc2<BDim2 ; ++cc2 )
        for ( cc1=0 ; cc1<ADim2 ; ++cc1 )
            for ( cr1=0 ; cr1<ADim1 ; ++cr1 )
                C[cc2*ADim2+cr1] += A[cc1*ADim1+cr1]*B[cc2*BDim1+cc1];
}

Ho due domande:

  1. Dato che una moltiplicazione matrice-matrice si dice: nxm * mxn richiede n * n * m moltiplicazioni, quindi nel caso sopra 1000 ^ 3 o 1e9 operazioni. Come è possibile sul mio processore da 2,6 Ghz per BLAS eseguire operazioni 10 * 1e9 in 1,32 secondi? Anche se le multiplcazioni fossero una singola operazione e non fosse stato fatto nient'altro, dovrebbero essere necessari circa 4 secondi.
  2. Perché la mia implementazione è molto più lenta?

17
BLAS è stato ottimizzato da un lato e dall'altro da specialisti del settore. Presumo che stia sfruttando l'unità in virgola mobile SIMD sul tuo chip e sta giocando un sacco di trucchi per migliorare anche il comportamento di caching ...
dmckee --- ex-moderatore gattino

3
Tuttavia, come si eseguono operazioni 1E10 su un processore da 2.63E9 cicli / secondo in 1,3 secondi?
DeusAduro

9
Più unità di esecuzione, rivestimento di tubi e dati multipli di istruzione singola ((SIMD) che significa eseguire la stessa operazione su più di una coppia di operandi contemporaneamente). Alcuni compilatori possono indirizzare le unità SIMD su chip comuni, ma devi quasi sempre attivarli esplicitamente, e aiuta a sapere come funziona il tutto ( en.wikipedia.org/wiki/SIMD ). Assicurarsi contro i cache miss è quasi certamente la parte difficile.
dmckee --- gattino ex moderatore

13
L'assunzione è sbagliata. Ci sono algoritmi migliori conosciuti, vedi Wikipedia.
MSalters

2
@DeusAduro: Nella mia risposta a Come scrivere un prodotto a matrice a matrice che possa competere con Eigen? Ho pubblicato un piccolo esempio su come implementare un prodotto matrice-matrice efficiente per la cache.
Michael Lehn

Risposte:


141

Un buon punto di partenza è il grande libro The Science of Programming Matrix Computations di Robert A. van de Geijn e Enrique S. Quintana-Ortí. Forniscono una versione scaricabile gratuitamente.

BLAS è suddiviso in tre livelli:

  • Il livello 1 definisce un insieme di funzioni di algebra lineare che operano solo sui vettori. Queste funzioni traggono vantaggio dalla vettorizzazione (ad esempio dall'uso di SSE).

  • Le funzioni di livello 2 sono operazioni matrice-vettore, ad esempio alcuni prodotti matrice-vettore. Queste funzioni potrebbero essere implementate in termini di funzioni di Livello1. Tuttavia, è possibile aumentare le prestazioni di queste funzioni se è possibile fornire un'implementazione dedicata che fa uso di un'architettura multiprocessore con memoria condivisa.

  • Le funzioni di livello 3 sono operazioni come il prodotto matrice-matrice. Ancora una volta potresti implementarli in termini di funzioni Level2. Ma le funzioni di livello3 eseguono operazioni O (N ^ 3) sui dati O (N ^ 2). Quindi, se la tua piattaforma ha una gerarchia della cache, puoi migliorare le prestazioni se fornisci un'implementazione dedicata ottimizzata per la cache / adatta alla cache . Questo è ben descritto nel libro. Il vantaggio principale delle funzioni di Level3 deriva dall'ottimizzazione della cache. Questo boost supera in modo significativo il secondo boost dal parallelismo e da altre ottimizzazioni hardware.

A proposito, la maggior parte (o anche tutte) delle implementazioni BLAS ad alte prestazioni NON sono implementate in Fortran. ATLAS è implementato in C. GotoBLAS / OpenBLAS è implementato in C e le sue parti critiche per le prestazioni in Assembler. Solo l'implementazione di riferimento di BLAS è implementata in Fortran. Tuttavia, tutte queste implementazioni BLAS forniscono un'interfaccia Fortran tale che possa essere collegata a LAPACK (LAPACK ottiene tutte le sue prestazioni da BLAS).

I compilatori ottimizzati giocano un ruolo minore in questo senso (e per GotoBLAS / OpenBLAS il compilatore non ha alcuna importanza).

L'implementazione IMHO no BLAS utilizza algoritmi come l'algoritmo Coppersmith – Winograd o l'algoritmo Strassen. Non sono esattamente sicuro del motivo, ma questa è la mia ipotesi:

  • Forse non è possibile fornire un'implementazione ottimizzata per la cache di questi algoritmi (ovvero perderesti più di quanto vinceresti)
  • Questi algoritmi non sono numericamente stabili. Poiché BLAS è il kernel computazionale di LAPACK, questo è un no-go.

Modifica / Aggiornamento:

Il documento nuovo e innovativo per questo argomento sono i documenti BLIS . Sono scritti eccezionalmente bene. Per la mia lezione "Nozioni di base sul software per il calcolo ad alte prestazioni" ho implementato il prodotto matrice-matrice seguendo il loro articolo. In realtà ho implementato diverse varianti del prodotto matrice-matrice. Le varianti più semplici sono interamente scritte in C normale e hanno meno di 450 righe di codice. Tutte le altre varianti si limitano a ottimizzare i loop

    for (l=0; l<MR*NR; ++l) {
        AB[l] = 0;
    }
    for (l=0; l<kc; ++l) {
        for (j=0; j<NR; ++j) {
            for (i=0; i<MR; ++i) {
                AB[i+j*MR] += A[i]*B[j];
            }
        }
        A += MR;
        B += NR;
    }

Le prestazioni complessive del prodotto matrice-matrice dipendono solo da questi loop. Circa il 99,9% del tempo viene trascorso qui. Nelle altre varianti ho utilizzato intrinseci e codice assembler per migliorare le prestazioni. Puoi vedere il tutorial che esamina tutte le varianti qui:

ulmBLAS: Tutorial su GEMM (prodotto Matrix-Matrix)

Insieme ai documenti BLIS diventa abbastanza facile capire come le biblioteche come Intel MKL possano ottenere tali prestazioni. E perché non importa se utilizzi l'archiviazione principale di righe o colonne!

I benchmark finali sono qui (abbiamo chiamato il nostro progetto ulmBLAS):

Benchmark per ulmBLAS, BLIS, MKL, openBLAS e Eigen

Un'altra modifica / aggiornamento:

Ho anche scritto un tutorial su come BLAS viene utilizzato per problemi di algebra lineare numerica come la risoluzione di un sistema di equazioni lineari:

Fattorizzazione LU ad alte prestazioni

(Questa fattorizzazione LU è ad esempio utilizzata da Matlab per risolvere un sistema di equazioni lineari.)

Spero di trovare il tempo per estendere il tutorial per descrivere e dimostrare come realizzare un'implementazione parallela altamente scalabile della fattorizzazione LU come in PLASMA .

Ok, ecco qui: Codifica di una fattorizzazione LU parallela ottimizzata per la cache

PS: ho anche fatto alcuni esperimenti per migliorare le prestazioni di uBLAS. In realtà è abbastanza semplice aumentare (sì, gioca con le parole :)) le prestazioni di uBLAS:

Esperimenti su uBLAS .

Ecco un progetto simile con BLAZE :

Esperimenti su BLAZE .


3
Nuovo collegamento a "Benchmarks for ulmBLAS, BLIS, MKL, openBLAS ed Eigen": apfel.mathematik.uni-ulm.de/~lehn/ulmBLAS/#toc3
Ahmed Fasih

Si scopre che l'ESSL di IBM utilizza una variazione dell'algoritmo di Strassen - ibm.com/support/knowledgecenter/en/SSFHY8/essl_welcome.html
ben-albrecht

2
la maggior parte dei collegamenti sono morti
Aurélien Pierre

Un PDF di TSoPMC può essere trovato sulla pagina dell'autore, a cs.utexas.edu/users/rvdg/tmp/TSoPMC.pdf
Alex Shpilkin

Sebbene l'algoritmo Coppersmith-Winograd abbia una bella complessità temporale sulla carta, la notazione Big O nasconde una costante molto grande, quindi inizia a diventare utilizzabile solo per matrici ridicolmente grandi.
DiehardTheTryhard

26

Quindi prima di tutto BLAS è solo un'interfaccia di circa 50 funzioni. Ci sono molte implementazioni concorrenti dell'interfaccia.

Innanzitutto menzionerò cose che sono in gran parte non correlate:

  • Fortran vs C, non fa differenza
  • Algoritmi di matrice avanzati come Strassen, le implementazioni non li usano perché non aiutano nella pratica

La maggior parte delle implementazioni suddividono ogni operazione in matrici di piccole dimensioni o operazioni vettoriali in modo più o meno ovvio. Ad esempio, una grande moltiplicazione di matrici 1000x1000 può essere suddivisa in una sequenza di moltiplicazioni di matrici 50x50.

Queste operazioni di piccole dimensioni a dimensione fissa (chiamate kernel) sono codificate in codice assembly specifico della CPU utilizzando diverse funzionalità della CPU del loro obiettivo:

  • Istruzioni in stile SIMD
  • Parallelismo a livello di istruzione
  • Cache-consapevolezza

Inoltre questi kernel possono essere eseguiti in parallelo l'uno rispetto all'altro utilizzando più thread (core CPU), nel tipico modello di progettazione map-reduce.

Dai un'occhiata ad ATLAS che è l'implementazione BLAS open source più comunemente utilizzata. Ha molti kernel concorrenti diversi e durante il processo di compilazione della libreria ATLAS esegue una competizione tra di loro (alcuni sono persino parametrizzati, quindi lo stesso kernel può avere impostazioni diverse). Prova diverse configurazioni e quindi seleziona la migliore per il particolare sistema di destinazione.

(Suggerimento: questo è il motivo per cui se stai usando ATLAS è meglio costruire e mettere a punto la libreria a mano per la tua macchina particolare, quindi usarne una precostruita.)


ATLAS non è più l'implementazione BLAS open source più comunemente utilizzata. È stato superato da OpenBLAS (un fork del GotoBLAS) e BLIS (un refactoring del GotoBLAS).
Robert van de Geijn,

1
@ ulaff.net: forse. Questo è stato scritto 6 anni fa. Penso che l'implementazione BLAS più veloce attualmente (su Intel ovviamente) sia Intel MKL, ma non è open source.
Andrew Tomazos

14

Innanzitutto, esistono algoritmi più efficienti per la moltiplicazione di matrici rispetto a quello che stai utilizzando.

In secondo luogo, la tua CPU può eseguire più di un'istruzione alla volta.

La CPU esegue 3-4 istruzioni per ciclo e, se vengono utilizzate le unità SIMD, ciascuna istruzione elabora 4 float o 2 double. (ovviamente anche questa cifra non è accurata, poiché la CPU può in genere elaborare solo un'istruzione SIMD per ciclo)

Terzo, il tuo codice è tutt'altro che ottimale:

  • Stai usando puntatori non elaborati, il che significa che il compilatore deve presumere che possano alias. Ci sono parole chiave o flag specifici del compilatore che puoi specificare per dire al compilatore che non hanno alias. In alternativa, dovresti usare altri tipi oltre ai puntatori grezzi, che si prendono cura del problema.
  • Stai distruggendo la cache eseguendo un attraversamento ingenuo di ogni riga / colonna delle matrici di input. È possibile utilizzare il blocco per eseguire quanto più lavoro possibile su un blocco più piccolo della matrice, che si adatta alla cache della CPU, prima di passare al blocco successivo.
  • Per le attività puramente numeriche, Fortran è praticamente imbattibile e il C ++ richiede molte attenzioni per raggiungere una velocità simile. Si può fare, e ci sono un paio di librerie che dimostrano (tipicamente utilizzando i modelli di espressione), ma non è banale, e non solo accadere.

Grazie, ho aggiunto limitare il codice corretto secondo il suggerimento di Justicle, non ho visto molti miglioramenti, mi piace l'idea a blocchi. Per curiosità, senza conoscere la dimensione della cache della CPU come farebbe un codice ottimale a destra?
DeusAduro

2
Non lo fai. Per ottenere un codice ottimale, è necessario conoscere la dimensione della cache della CPU. Ovviamente lo svantaggio di questo è che stai effettivamente codificando il tuo codice per ottenere le migliori prestazioni su una famiglia di CPU.
jalf

2
Almeno il ciclo interno qui evita i carichi striduli. Sembra che questo sia scritto per una matrice già trasposta. Ecco perché è "solo" un ordine di grandezza più lento del BLAS! Ma sì, continua a battere a causa della mancanza di blocco della cache. Sei sicuro che Fortran sarebbe di grande aiuto? Penso che tutto ciò che otterresti qui è che restrict(nessun aliasing) è l'impostazione predefinita, a differenza di C / C ++. (E sfortunatamente ISO C ++ non ha una restrictparola chiave, quindi devi usarla __restrict__sui compilatori che la forniscono come estensione).
Peter Cordes

11

Non so specificatamente sull'implementazione BLAS ma ci sono alogoritmi più efficienti per la moltiplicazione di matrici che ha una complessità migliore di O (n3). Uno ben noto è Strassen Algorithm


8
L'algoritmo di Strassen non viene utilizzato in numerics per due motivi: 1) Non è stabile. 2) Risparmi alcuni calcoli ma questo comporta il prezzo che puoi sfruttare le gerarchie della cache. In pratica perdi anche la prestazione.
Michael Lehn

4
Per l'implementazione pratica di Strassen Algorithm strettamente costruito sul codice sorgente della libreria BLAS, c'è una recente pubblicazione: " Strassen Algorithm Reloaded " in SC16, che raggiunge prestazioni superiori a BLAS, anche per la dimensione del problema 1000x1000.
Jianyu Huang

4

La maggior parte degli argomenti per la seconda domanda - assemblatore, suddivisione in blocchi ecc. (Ma non meno degli algoritmi N ^ 3, sono davvero troppo sviluppati) - giocano un ruolo. Ma la bassa velocità del tuo algoritmo è causata essenzialmente dalla dimensione della matrice e dalla sfortunata disposizione dei tre loop annidati. Le tue matrici sono così grandi che non si adattano immediatamente alla memoria cache. È possibile riorganizzare i loop in modo tale che il più possibile verrà eseguito su una riga nella cache, riducendo così drasticamente gli aggiornamenti della cache (la divisione BTW in piccoli blocchi ha un effetto analogico, meglio se i loop sui blocchi sono disposti in modo simile). Segue un'implementazione del modello per matrici quadrate. Sul mio computer il suo consumo di tempo era di circa 1:10 rispetto all'implementazione standard (come la tua). In altre parole: non programmare mai una moltiplicazione di matrici lungo "

    void vector(int m, double ** a, double ** b, double ** c) {
      int i, j, k;
      for (i=0; i<m; i++) {
        double * ci = c[i];
        for (k=0; k<m; k++) ci[k] = 0.;
        for (j=0; j<m; j++) {
          double aij = a[i][j];
          double * bj = b[j];
          for (k=0; k<m; k++)  ci[k] += aij*bj[k];
        }
      }
    }

Un'ultima osservazione: questa implementazione è ancora migliore sul mio computer che sostituire tutto con la routine BLAS cblas_dgemm (provalo sul tuo computer!). Ma molto più velocemente (1: 4) chiama direttamente dgemm_ della libreria Fortran. Penso che questa routine in effetti non sia Fortran ma codice assembler (non so cosa c'è nella libreria, non ho i sorgenti). Completamente poco chiaro per me è il motivo per cui cblas_dgemm non è così veloce poiché per quanto ne so è semplicemente un wrapper per dgemm_.


3

Questa è una velocità realistica. Per un esempio di cosa si può fare con l'assemblatore SIMD su codice C ++, guarda alcuni esempi di funzioni di matrice per iPhone : erano oltre 8 volte più veloci della versione C e non sono nemmeno assemblati "ottimizzati" - non c'è ancora nessun rivestimento per i tubi e lì sono operazioni di stack non necessarie.

Inoltre il tuo codice non è " restrittivo corretto " - come fa il compilatore a sapere che quando modifica C, non sta modificando A e B?


Certo se hai chiamato la funzione come mmult (A ..., A ..., A); certamente non otterresti il ​​risultato atteso. Anche in questo caso, anche se non stavo cercando di battere / reimplementare BLAS, vedendo solo quanto è veloce, quindi il controllo degli errori non era in mente, ma solo la funzionalità di base.
DeusAduro

3
Scusa, per essere chiari, quello che sto dicendo è che se metti "limitare" i tuoi puntatori, otterrai un codice molto più veloce. Questo perché ogni volta che modifichi C, il compilatore non deve ricaricare A e B, accelerando notevolmente il ciclo interno. Se non mi credi, controlla lo smontaggio.
Justicle

@DeusAduro: questo non è un controllo degli errori: è possibile che il compilatore non sia in grado di ottimizzare gli accessi all'array B [] nel ciclo interno perché potrebbe non essere in grado di capire che i puntatori A e C non alias mai B Vettore. Se ci fosse un alias, sarebbe possibile che il valore nell'array B cambi durante l'esecuzione del ciclo interno. Sollevare l'accesso al valore B [] dal ciclo interno e inserirlo in una variabile locale potrebbe consentire al compilatore di evitare accessi continui a B [].
Michael Burr

1
Hmmm, quindi ho provato prima a utilizzare la parola chiave "__restrict" in VS 2008, applicata ad A, B e C. Questo non ha mostrato alcun cambiamento nel risultato. Tuttavia, spostando l'accesso a B, dal loop più interno al loop esterno, il tempo è migliorato del ~ 10%.
DeusAduro

1
Mi spiace, non sono sicuro di VC, ma con GCC devi abilitare -fstrict-aliasing. C'è anche una spiegazione migliore di "limitare" qui: cellperformance.beyond3d.com/articles/2006/05/…
Justicle

2

Rispetto al codice originale in MM moltiplica, il riferimento alla memoria per la maggior parte delle operazioni è la causa principale delle cattive prestazioni. La memoria è 100-1000 volte più lenta della cache.

La maggior parte dell'accelerazione deriva dall'impiego di tecniche di ottimizzazione del ciclo per questa funzione a triplo ciclo nella moltiplicazione MM. Vengono utilizzate due principali tecniche di ottimizzazione del ciclo; srotolamento e blocco. Per quanto riguarda lo srotolamento, srotoliamo i due cicli più esterni e li blocchiamo per il riutilizzo dei dati nella cache. Lo srotolamento del loop esterno aiuta a ottimizzare l'accesso ai dati temporalmente riducendo il numero di riferimenti di memoria agli stessi dati in momenti diversi durante l'intera operazione. Il blocco dell'indice del ciclo a un numero specifico aiuta a conservare i dati nella cache. Puoi scegliere di ottimizzare per la cache L2 o la cache L3.

https://en.wikipedia.org/wiki/Loop_nest_optimization


-24

Per molte ragioni.

Innanzitutto, i compilatori Fortran sono altamente ottimizzati e il linguaggio consente loro di essere tali. C e C ++ sono molto flessibili in termini di gestione degli array (ad esempio, il caso di puntatori che fanno riferimento alla stessa area di memoria). Ciò significa che il compilatore non può sapere in anticipo cosa fare ed è costretto a creare codice generico. In Fortran, i tuoi casi sono più snelli e il compilatore ha un controllo migliore di ciò che accade, permettendogli di ottimizzare di più (ad esempio utilizzando i registri).

Un'altra cosa è che Fortran archivia le cose a colonne, mentre C memorizza i dati per riga. Non ho controllato il tuo codice, ma fai attenzione a come esegui il prodotto. In C è necessario eseguire la scansione delle righe: in questo modo si esegue la scansione dell'array lungo la memoria contigua, riducendo i problemi di cache. La mancanza di cache è la prima fonte di inefficienza.

Terzo, dipende dall'implementazione blas che stai usando. Alcune implementazioni potrebbero essere scritte in assembler e ottimizzate per il processore specifico che stai utilizzando. La versione netlib è scritta in fortran 77.

Inoltre, stai facendo molte operazioni, la maggior parte delle quali ripetute e ridondanti. Tutte quelle moltiplicazioni per ottenere l'indice sono dannose per la performance. Non so davvero come sia fatto in BLAS, ma ci sono molti trucchi per prevenire operazioni costose.

Ad esempio, potresti rielaborare il tuo codice in questo modo

template<class ValT>
void mmult(const ValT* A, int ADim1, int ADim2, const ValT* B, int BDim1, int BDim2, ValT* C)
{
if ( ADim2!=BDim1 ) throw std::runtime_error("Error sizes off");

memset((void*)C,0,sizeof(ValT)*ADim1*BDim2);
int cc2,cc1,cr1, a1,a2,a3;
for ( cc2=0 ; cc2<BDim2 ; ++cc2 ) {
    a1 = cc2*ADim2;
    a3 = cc2*BDim1
    for ( cc1=0 ; cc1<ADim2 ; ++cc1 ) {
          a2=cc1*ADim1;
          ValT b = B[a3+cc1];
          for ( cr1=0 ; cr1<ADim1 ; ++cr1 ) {
                    C[a1+cr1] += A[a2+cr1]*b;
           }
     }
  }
} 

Provalo, sono sicuro che salverai qualcosa.

Nella tua domanda n. 1, il motivo è che la moltiplicazione di matrici scala come O (n ^ 3) se usi un algoritmo banale. Esistono algoritmi che scalano molto meglio .


36
Questa risposta è completamente sbagliata, mi dispiace. Le implementazioni BLAS non sono scritte in fortran. Il codice critico per le prestazioni è scritto in assembly e quelli più comuni in questi giorni sono scritti in C sopra. Inoltre BLAS specifica l'ordine di riga / colonna come parte dell'interfaccia e le implementazioni possono gestire qualsiasi combinazione.
Andrew Tomazos

10
Sì, questa risposta è completamente sbagliata. Sfortunatamente è pieno di nonsenso comune, ad esempio l'affermazione BLAS era più veloce grazie a Fortran. Avere 20 (!) Valutazioni positive è una brutta cosa. Ora questo non senso si diffonde ulteriormente a causa della popolarità di Stackoverflow!
Michael Lehn

12
Penso che tu stia confondendo l'implementazione di riferimento non ottimizzata con le implementazioni di produzione. L'implementazione di riferimento serve solo a specificare l'interfaccia e il comportamento della libreria ed è stata scritta in Fortran per ragioni storiche. Non è per uso di produzione. Nella produzione le persone usano implementazioni ottimizzate che mostrano lo stesso comportamento dell'implementazione di riferimento. Ho studiato gli interni di ATLAS (che supporta Octave - Linux "MATLAB") che posso confermare di prima mano è scritto internamente in C / ASM. Quasi certamente lo sono anche le implementazioni commerciali.
Andrew Tomazos

5
@KyleKanos: Sì, ecco il sorgente di ATLAS: sourceforge.net/projects/math-atlas/files/Stable/3.10.1 Per quanto ne so, è l'implementazione BLAS portatile open source più comunemente usata. È scritto in C / ASM. I produttori di CPU ad alte prestazioni come Intel, forniscono anche implementazioni BLAS particolarmente ottimizzate per i loro chip. Garantisco che le parti di basso livello della libreria Intels sono scritte in (duuh) assembly x86, e sono abbastanza sicuro che le parti di medio livello sarebbero scritte in C o C ++.
Andrew Tomazos

9
@ KyleKanos: Sei confuso. Netlib BLAS è l'implementazione di riferimento. L'implementazione di riferimento è molto più lenta delle implementazioni ottimizzate (vedere il confronto delle prestazioni ). Quando qualcuno dice che sta usando netlib BLAS su un cluster, non significa che stia effettivamente usando l'implementazione di riferimento netlib. Sarebbe semplicemente stupido. Significa solo che stanno usando una libreria con la stessa interfaccia di netlib blas.
Andrew Tomazos
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.