Interprète

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 multidimensionnel index sous la forme Element .
  • void Tensor::set(llvm::ArrayRef<int64_t> index, Element element);: Pour mettre à jour un objet Element element en un Tensor multidimensionnel l'index index.

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:

  1. Effectue le suivi des arguments SSA de func et de l'environnement d'exécution Tensor associé fournies dans args, à l'aide d'un mappage de table de symboles, M.
  2. 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'appel eval.
    • Suit le ou les résultats SSA de l'opération et la valeur évaluée dans M.

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:

  1. 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ération add, 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.

  2. 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ération add, 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 API llvm::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 ?

  1. 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.)
  2. 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)