Modèle de données
Les programmes StableHLO sont des calculs sur des Tensors.
(tableaux à n dimensions) qui, dans le modèle actuel, sont implémentés à l'aide de la
classe Tensor
. La classe de stockage sous-jacente d'un objet Tensor
detail::Buffer
, stocke la valeur mlir::ShapedType
du Tensor avec un élément
Objet mlir::HeapAsmResourceBlob
représentant un blob modifiable de Tensor
présentées sous la forme d'un tableau d'octets contigus
ordre décroissant.
Les objets detail::Buffer
sont comptés par référence pour simplifier la gestion de la mémoire.
Les éléments individuels d'un Tensor sont représentés à l'aide de la classe Element
, qui
utilise une union discriminée contenant l'une des valeurs APInt
, APFloat
ou
pair<APFloat,APFloat>
pour le stockage. Le dernier est utilisé pour
stocker des éléments
avec des types complexes.
Tensor
dispose des API suivantes pour interagir avec ses éléments individuels:
Element Tensor::get(llvm::ArrayRef<int64_t> index)
: pour extraire un élément de Tensor individuel à l'indice multidimensionnelindex
sous la formeElement
.void Tensor::set(llvm::ArrayRef<int64_t> index, Element element);
: Pour mettre à jour un objetElement
element
en un Tensor multidimensionnel l'indexindex
.
Fonctionnement de l'interprète
La fonction d'entrée de l'interpréteur est
SmallVector<Tensor> eval(func::FuncOp func, ArrayRef<Tensor> args);
qui effectue les opérations suivantes:
- Effectue le suivi des arguments SSA de
func
et de l'environnement d'exécutionTensor
associé fournies dansargs
, à l'aide d'un mappage de table de symboles, M. - Pour chaque opération dans
func
, dans l'ordre SSACFG: <ph type="x-smartling-placeholder">- </ph>
- Elle appelle
eval
sur l'opération. Pour chaque opérande SSA de l'opération, extrayez ses la valeur d'environnement d'exécution de M à fournir en tant qu'argument à l'appeleval
. - Suit le ou les résultats SSA de l'opération et la valeur évaluée dans M.
- Elle appelle
Le eval
de niveau d'opération mentionné à l'étape (2) est responsable de l'implémentation de la
la sémantique d'exécution de l'opération. Voici un exemple pour stablehlo::AddOp
.
Dans l'exemple, les éléments individuels des Tensors lhs
et rhs
sont par paire.
extraits en tant qu'objets Element
, qui sont ensuite ajoutés. Le résultat de cette addition,
un objet Element
, est stocké dans le Tensor final result
.
Tensor eval(AddOp op, const Tensor &lhs, const Tensor &rhs) {
Tensor result(op.getType());
for (auto it = result.index_begin(); it != result.index_end(); ++it)
result.set(*it, lhs.get(*it) + rhs.get(*it));
return result;
}
De manière générale, la conception de l'interpréteur
est optimisée pour la lisibilité
des implémentations de fonctions eval
pour des opérations individuelles, car elles sont destinées
serviront de référence pour StableHLO. Par exemple, au lieu de
définir eval
comme fonction de modèle et le paramétrer avec des types d'éléments ;
nous encapsulons des détails sur la façon
dont les différents types d'éléments sont gérés dans
Element::operator+
, etc., ce qui simplifie l'implémentation de eval
.
Utiliser l'interpréteur pour un pliage constant
Nous pouvons utiliser le mécanisme d'interpréteur pour plier les opérations avec un opérande constant.
valeurs. L'extrait de code suivant illustre l'implémentation
Pour plier stablehlo::AddOp
avec des opérandes typés à virgule flottante:
OpFoldResult AddOp::fold(FoldAdaptor adaptor) {
auto attrs = adaptor.getOperands();
DenseElementsAttr lhsData = dyn_cast<DenseElementsAttr>(attrs[0]);
DenseElementsAttr rhsData = dyn_cast<DenseElementsAttr>(attrs[1]);
if (!lhsData || !rhsData) return {};
auto lhs = Tensor(lhsData);
auto rhs = Tensor(rhsData);
auto result = eval(*this, lhs, rhs);
SmallVector<APFloat> values;
for (auto i = 0; i < result.getNumElements(); ++i) {
Element element = result.get(i);
values.push_back(cast<FloatAttr>(element.getValue()).getValue());
}
return DenseElementsAttr::get(result.getType(), values);
}
Pour le moment, nous ne travaillons pas activement à l'intégration de l'interprète dans
un pliage constant, car nous ne prévoyons pas
d'implémenter un dossier pour StableHLO.
Cependant, à l'avenir, nous prévoyons d'utiliser l'interprète pour obtenir
en pliant MHLO, ce qui permet d'améliorer l'ergonomie de l'extrait de code
(par exemple, une fonction d'assistance qui regroupe les opérandes constants dans
Tensor
et décompresse les résultats Tensor
en OpFoldResult
).
Tester l'interpréteur StableHLO
L'interpréteur prend en entrée (A) un programme StableHLO et (B) des valeurs de données pour
fournies au programme et génère les valeurs des données de sortie, qui sont mises en correspondance
par rapport aux valeurs de données attendues fournies par l'utilisateur. Les valeurs de données (B) sont
codées en dur dans le programme lui-même à l'aide d'opérations stablehlo.constant
. La
l'interprète évalue le programme d'entrée. La ou les sorties de l'opération testée
est vérifié par le biais de vérifications (par exemple, check.expect_eq
, check.expect_almost_eq
), comme
comme indiqué ci-dessous. check.expect_eq
et check.expect_eq_const
vérifient la présence au niveau du bit
l'égalité pour tout type pris en charge, et check.expect_almost_eq
et
check.expect_almost_eq_const
vérifient la quasi-égalité dans une tolérance.
expliqué dans les consignes relatives aux tests (G6), pour les types à virgule flottante et complexes.
// CHECK-LABEL: Evaluated results of function: add_op_test_ui4
func.func @add_op_test_ui4() {
%0 = stablehlo.constant dense<[0, 2]> : tensor<2xui4>
%1 = stablehlo.constant dense<[15, 3]> : tensor<2xui4>
%2 = stablehlo.add %0, %1 : tensor<2xui4>
check.expect_eq_const %2, [15, 5] : tensor<2xui4>
func.return
}
Un utilitaire de test stablehlo-translate --interpret
(code)
est responsable de l'analyse du programme, de l'interprétation de chaque fonction, y compris du
opérations qui constituent la fonction. Nous disposons d'une suite de tests dédiée,
plusieurs tests portant sur différents comportements d'exécution, pour chaque opération StableHLO.
Les tests sont disponibles sur cette page.
Consignes liées aux tests
(G1) Devons-nous tester tous les types compatibles pour chaque opération ?
Nous pouvons utiliser une combinaison des règles suivantes pour prendre une décision:
Lors de l'implémentation d'une opération, s'il existe du code dans l'
eval
correspondante pour gérer un type particulier, il est impératif de disposer d'un ou de plusieurs tests pour couvrir ce type. Par exemple, pour l'opérationadd
, il existe du code exclusif pour gérer les types entiers, booléens, à virgule flottante et complexes. C'est pourquoi nous ont besoin d'un test pour chaque catégorie de types.Si un ensemble de types est géré de manière uniforme dans la fonction
eval
correspondante, alors un seul test pour tous ces types devrait suffire. Par exemple, pour l'opérationadd
, toutes les variantes des types entiers (si4
,u4
,si8
,u8
) et ainsi de suite) sont gérés de la même manière à l'aide des APIllvm::APInt
. Nous pouvons donc ignorer en ajoutant des tests pour chacune de ces variantes et en ajoutant à la place un seul test représentatif. Pour éviter toute ambiguïté dans le choix du représentant, nous vous devez respecter les consignes suivantes:- Si tous les types, gérés de manière uniforme, ont le même type primitif (c'est-à-dire s'il s'agit de types entiers, à virgule flottante ou complexes), alors choisir celle avec la largeur de bits maximale.
- Si tous les types, gérés de manière uniforme, incluent une combinaison de types primitifs, alors choisissez celui associé au type primitif suivant, par ordre décroissant préférence: entier, à virgule flottante, booléen, complexe.
(G2) Comment déterminons-nous le nombre de tests nécessaires pour couvrir l'état de sécurité ?
L'objectif est de couvrir de manière exhaustive la logique de l'interprète pour l'opération. (c'est-à-dire tous les cas particuliers de l'implémentation) avec un nombre minimal de tests. Il est important de limiter le nombre de tests pour faciliter la gestion. Moins il y a de tests plus il est facile de les examiner et de s'assurer pour couvrir l'opération de manière exhaustive. Par conséquent, nous nous attendons à ce que la plupart des les opérations n'auront qu'un seul test. Si, pour une raison quelconque, n'est pas pratique, il est alors acceptable de s'arrêter à 90 % ou plus. Ce choix sera décidé au cas par cas lors de l'examen des demandes d'extraction.
(G3) Pourquoi ne pas ajouter des tests pour l'infrastructure de l'interpréteur ?
L'infrastructure de l'interprète est pour la plupart simple et peut être ajoutée
notre base de confiance. La seule partie non triviale est
la façon dont les différents types sont empaquetés dans
et décompressé du stockage sous-jacent de l'interpréteur. Comme nous l'avons vu à l'étape G1,
ne testeront que les types d'opérations gérés différemment. Avec
que le code d'empaquetage/de décompression, correspondant à différentes
de types entiers/à virgule flottante peuvent ne pas être entièrement couverts pendant
tests. Pour garantir une couverture complète, nous pouvons choisir une opération comme constant
qui
est compatible avec tous les types d'éléments StableHLO et écrivez des tests exhaustifs.
(G4) Si l'implémentation d'une opération dépend d'autres opérations, devons-nous écrire pour le second ?
Non. Par exemple, l'implémentation de batch_norm_grad
peut être basée sur
divide
, subtract
, multiply
et d'autres. Nous devons éviter de tester la deuxième
tout en testant la première.
(G5) Faut-il écrire des tests pour tester l'implémentation définie / non définie de sécurité ?
Nous ne devons pas écrire de tests qui appliquent les stratégies définies par l'implémentation comportements indéfinis de l'opération. Tests exerçant des comportements définis par l'implémentation de démontrer un comportement local de l'interprète, qui ne doit pas être généralisée. Les tests présentant un comportement non défini ne contribuent pas à la compréhension du comportement de l'opération.
(G6) Lors de l'écriture de tests pour des types à virgule flottante, avec quelle précision le le résultat attendu doivent-ils être spécifiés lors des contrôles ?
Pour les opérations élémentaires (addition, soustraction, multiplication, division et
carré), une mise en œuvre conforme à la spécification IEEE doit fournir une
résultat arrondi situé à une distance de 0,5 ULP du résultat mathématique exact. Cela dit, nous
imaginer sans risque que le résultat attendu de ces opérations soit
à une distance d'1 ULP. Toutefois, cela peut ne pas fonctionner pour les fonctions transcendantales.
(sine
, cosine
, etc.) pour lesquelles les garanties de précision sont
définie par l'implémentation (rationalisation).
La mise en œuvre actuelle utilise un modèle de 0,0001. L'exemple suivant illustre la tolérance ci-dessus en action.
func.func @check_tolerance() {
%0 = stablehlo.constant dense<0.2> : tensor<f32>
// The following check succeeds as %0 is almost equal to the provided
// constant modulo the tolerance, mentioned above.
check.expect_almost_eq_const %0, dense<0.19999> : tensor<f32>
// The following check fails as %0 is not bitwise equal to the provided
// constant.
check.expect_eq_const %0, dense<0.19999> : tensor<f32>
func.return
}
Il ne s'agit que de la première étape pour tester la précision numérique des opérations StableHLO. Pour le moment, il s'agit d'une zone sous-spécifiée de la spécification StableHLO. pour parvenir à une solution. #1156 sur la base de notre expérience d'utilisation de StableHLO et des commentaires de partenaires. Au fur et à mesure de nos progrès, nous mettrons à jour l'infrastructure en conséquence.
(G7) Qu'en est-il du style de codage des tests ?
- Veillez à utiliser le nom réel des entrées/sorties au lieu de la valeur par défaut. aux valeurs SSA (par exemple %0, %1, etc.)
- Assurez-vous que les tests utilisent un format bien imprimé, le cas échéant.
(G8) Devons-nous inclure l'exemple déjà fourni dans la spécification ? Oui (pour garantir l'exhaustivité des tests)