Descripción de arquitecturas de procesador en LLVM usando TableGen

En este momento, LLVM ya se ha convertido en un sistema muy popular, que muchas personas usan activamente para crear varios compiladores, analizadores, etc. Ya se ha escrito una gran cantidad de materiales útiles sobre este tema, incluso en ruso, lo cual es una buena noticia. Sin embargo, en la mayoría de los casos, el sesgo principal en los artículos se realiza en el LLVM frontend y middleend. Por supuesto, cuando se describe el esquema completo de la operación LLVM, no se omite la generación de código de máquina, pero básicamente este tema se trata de manera casual, especialmente en publicaciones en ruso. Al mismo tiempo, LLVM tiene un mecanismo bastante flexible e interesante para describir arquitecturas de procesador. Por lo tanto, este material se dedicará a la utilidad un tanto descuidada TableGen, que forma parte de LLVM.

La razón por la que el compilador necesita tener información sobre la arquitectura de cada una de las plataformas de destino es bastante obvia. Naturalmente, cada modelo de procesador tiene su propio conjunto de registros, sus propias instrucciones de máquina, etc. Y el compilador necesita tener toda la información necesaria sobre ellos para poder generar un código de máquina válido y eficiente. El compilador resuelve varias tareas específicas de la plataforma: distribuye registros, etc. Además, los backends de LLVM también llevan a cabo optimizaciones en el IR de la máquina, que está más cerca de las instrucciones reales o de las instrucciones del ensamblador. En tales optimizaciones, las instrucciones deben reemplazarse y transformarse; en consecuencia, toda la información sobre ellas debe estar disponible.

Para resolver el problema de describir la arquitectura del procesador, LLVM adoptó un formato único para determinar las propiedades del procesador necesarias para el compilador. Para cada arquitectura compatible, un .td contiene una descripción en un lenguaje formal especial. Se convierte en archivos .inc cuando se compila el compilador utilizando la utilidad TableGen incluida con LLVM. Los archivos resultantes, de hecho, son fuente C, pero tienen una extensión separada, muy probablemente, solo para que estos archivos generados automáticamente puedan distinguirse y filtrarse fácilmente. La documentación oficial de TableGen está aquí y proporciona toda la información necesaria, también hay una descripción formal del idioma y una introducción general .

Por supuesto, este es un tema muy extenso, donde hay muchos detalles sobre los cuales puede escribir artículos individuales. En este artículo, simplemente consideramos los puntos básicos de la descripción de los procesadores, incluso sin una descripción general de todas las características.

Descripción de la arquitectura en archivo .td


Entonces, el lenguaje de descripción formal utilizado en TableGen tiene características similares a los lenguajes de programación ordinarios y le permite describir las características de la arquitectura en un estilo declarativo. Y según tengo entendido, este lenguaje también se llama comúnmente TableGen. Es decir En este artículo, TableGen usa tanto el nombre del lenguaje formal como la utilidad que genera los artefactos resultantes a partir de él.

Los procesadores modernos son sistemas muy complejos, por lo que no es sorprendente que su descripción sea bastante voluminosa. En consecuencia, para crear la estructura y simplificar el mantenimiento de los archivos .td pueden incluir entre sí utilizando la directiva #include habitual para programadores en C. Con la ayuda de esta directiva, el archivo Target.td siempre se incluye primero y contiene interfaces independientes de la plataforma que deben implementarse para proporcionar toda la información necesaria de TableGen. Este archivo ya incluye un archivo .td con descripciones intrínsecas de LLVM, pero por sí mismo contiene principalmente clases base, como Register , Instruction , Processor , etc., de las cuales debe heredar para crear su propia arquitectura para el compilador basado en LLVM. De la oración anterior, está claro que TableGen tiene la noción de clases bien conocidas por todos los programadores.

En general, TableGen tiene solo dos entidades básicas: clases y definiciones .

Clases


Las clases TableGen también son abstracciones, como en todos los lenguajes de programación orientados a objetos, pero son entidades más simples.

Las clases pueden tener parámetros y campos, y también pueden heredar otras clases.
Por ejemplo, una de las clases base se presenta a continuación.

 // A class representing the register size, spill size and spill alignment // in bits of a register. class RegInfo<int RS, int SS, int SA> { int RegSize = RS; // Register size in bits. int SpillSize = SS; // Spill slot size in bits. int SpillAlignment = SA; // Spill slot alignment in bits. } 

Los corchetes angulares indican los parámetros de entrada que se asignan a las propiedades de la clase. A partir de este ejemplo, también puede observar que el lenguaje TableGen está estáticamente escrito. Los tipos que existen en TableGen: bit (un análogo del tipo booleano con valores 0 y 1), int , string , code (un fragmento de código, este es un tipo, simplemente porque TableGen no tiene métodos y funciones en el sentido habitual, las líneas de código están escritas en [{ ... }] ), bits <n>, lista <tipo> (los valores se establecen entre corchetes [...] como en Python y algunos otros lenguajes de programación), class type , dag .

La mayoría de los tipos deben entenderse, pero si tienen preguntas, todos se describen en detalle en la especificación del idioma, disponible en el enlace que se encuentra al principio del artículo.

La herencia también se describe mediante una sintaxis bastante familiar con :

 class X86MemOperand<string printMethod, AsmOperandClass parserMatchClass = X86MemAsmOperand> : Operand<iPTR> { let PrintMethod = printMethod; let MIOperandInfo = (ops ptr_rc, i8imm, ptr_rc_nosp, i32imm, SEGMENT_REG); let ParserMatchClass = parserMatchClass; let OperandType = "OPERAND_MEMORY"; } 

En este caso, la clase creada, por supuesto, puede anular los valores de los campos especificados en la clase base usando la palabra clave let . Y puede agregar sus propios campos similares a la descripción proporcionada en el ejemplo anterior, indicando el tipo de campo.

Definiciones


Las definiciones ya son entidades concretas, puede compararlas con lo familiar para todos los objetos. Las definiciones se definen usando la palabra clave def y pueden implementar una clase, redefinir campos de clases base exactamente de la misma manera que se describió anteriormente, y también tienen sus propios campos.

 def i8mem : X86MemOperand<"printbytemem", X86Mem8AsmOperand>; def X86AbsMemAsmOperand : AsmOperandClass { let Name = "AbsMem"; let SuperClasses = [X86MemAsmOperand]; } 

Multiclases


Naturalmente, una gran cantidad de instrucciones en los procesadores tienen una semántica similar. Por ejemplo, puede haber un conjunto de instrucciones de tres direcciones que toman las dos formas “reg = reg op reg” y “reg = reg op imm” . En un caso, los valores se toman de los registros y el resultado también se guarda en el registro, y en el otro caso, el segundo operando es un valor constante (imm - operando inmediato).

Listar todas las combinaciones manualmente es bastante tedioso; aumenta el riesgo de cometer un error. Por supuesto, pueden generarse automáticamente escribiendo un script simple, pero esto no es necesario, porque existe un concepto como multiclases en el lenguaje TableGen.

 multiclass ri_inst<int opc, string asmstr> { def _rr : inst<opc, !strconcat(asmstr, " $dst, $src1, $src2"), (ops GPR:$dst, GPR:$src1, GPR:$src2)>; def _ri : inst<opc, !strconcat(asmstr, " $dst, $src1, $src2"), (ops GPR:$dst, GPR:$src1, Imm:$src2)>; } 

Dentro de las multiclases, debe describir todas las formas posibles de instrucciones utilizando la palabra clave def . Pero esta no es una forma completa de instrucciones que se generarán. Al mismo tiempo, puede redefinir los campos en ellos y hacer todo lo posible en las definiciones habituales. Para crear definiciones reales basadas en una multiclase, debe usar la palabra clave defm .

 // Instantiations of the ri_inst multiclass. defm ADD : ri_inst<0b111, "add">; defm SUB : ri_inst<0b101, "sub">; defm MUL : ri_inst<0b100, "mul">; 

Y como resultado, para cada definición dada a través de defm de hecho, se construirán varias definiciones que son una combinación de la instrucción principal y todas las formas posibles descritas en la multiclase. Como resultado, se generarán las siguientes instrucciones en este ejemplo: ADD_rr , ADD_ri , SUB_rr , SUB_ri , MUL_rr , MUL_ri .

Las multiclases pueden contener no solo definiciones con def , sino también defm anidadas, lo que permite generar formas complejas de instrucciones. Un ejemplo que ilustra la creación de tales cadenas se puede encontrar en la documentación oficial.

Metas secundarias


Otra cosa básica y útil para los procesadores que tienen diferentes variaciones del conjunto de instrucciones es el soporte de subtarget en LLVM. Un ejemplo de uso es la implementación LLVM SPARC, que cubre tres versiones principales de la arquitectura de microprocesador SPARC a la vez: Versión 8 (V8, arquitectura de 32 bits), Versión 9 (V9, arquitectura de 64 bits) y arquitectura UltraSPARC. La diferencia entre las arquitecturas es bastante grande, un número diferente de registros de diferentes tipos, orden de bytes admitido, etc. En tales casos, si hay varias configuraciones, vale la pena implementar la clase XXXSubtarget para la arquitectura. El uso de esta clase en la descripción dará como resultado nuevas opciones de línea de comando -mcpu= y -mattr= .

Además de la clase Subtarget sí, la clase Subtarget importante.

 class SubtargetFeature<string n, string a, string v, string d, list<SubtargetFeature> i = []> { string Name = n; string Attribute = a; string Value = v; string Desc = d; list<SubtargetFeature> Implies = i; } 

En el archivo Sparc.td , puede encontrar ejemplos de la implementación de SubtargetFeature , que le permiten describir la disponibilidad de un conjunto de instrucciones para cada subtipo individual de la arquitectura.

 def FeatureV9 : SubtargetFeature<"v9", "IsV9", "true", "Enable SPARC-V9 instructions">; def FeatureV8Deprecated : SubtargetFeature<"deprecated-v8", "V8DeprecatedInsts", "true", "Enable deprecated V8 instructions in V9 mode">; def FeatureVIS : SubtargetFeature<"vis", "IsVIS", "true", "Enable UltraSPARC Visual Instruction Set extensions">; 

En este caso, de todos modos, Sparc.td todavía define la clase Proc , que se utiliza para describir subtipos específicos de procesadores SPARC, que podrían tener las propiedades descritas anteriormente, incluidos diferentes conjuntos de instrucciones.

 class Proc<string Name, list<SubtargetFeature> Features> : Processor<Name, NoItineraries, Features>; def : Proc<"generic", []>; def : Proc<"v8", []>; def : Proc<"supersparc", []>; def : Proc<"sparclite", []>; def : Proc<"f934", []>; def : Proc<"hypersparc", []>; def : Proc<"sparclite86x", []>; def : Proc<"sparclet", []>; def : Proc<"tsc701", []>; def : Proc<"v9", [FeatureV9]>; def : Proc<"ultrasparc", [FeatureV9, FeatureV8Deprecated]>; def : Proc<"ultrasparc3", [FeatureV9, FeatureV8Deprecated]>; def : Proc<"ultrasparc3-vis", [FeatureV9, FeatureV8Deprecated, FeatureVIS]>; 

Relación entre las propiedades de las instrucciones en TableGen y el código de fondo de LLVM


Las propiedades de las clases y definiciones le permiten generar y establecer correctamente las características arquitectónicas, pero no hay acceso directo a ellas desde el código fuente del back-end LLVM. Sin embargo, a veces desea poder obtener algunas propiedades de instrucciones específicas de la plataforma directamente en el código del compilador.

TSFlags


Para hacer esto, la clase base Instruction tiene un campo TSFlags especial, de 64 bits de tamaño, que TableGen convierte en un campo de objetos C ++ de la clase MCInstrDesc , generado sobre la base de los datos recibidos de la descripción de TableGen. Puede especificar cualquier cantidad de bits que necesite para almacenar información. Esto puede ser algún valor booleano, por ejemplo, para indicar que estamos usando una ALU escalar.

 let TSFlags{0} = SALU; 

O podemos almacenar el tipo de instrucción. Entonces necesitamos, por supuesto, más de un bit.

 // Instruction type according to the ISA. IType Type = type; let TSFlags{7-1} = Type.Value; 

Como resultado, es posible obtener estas propiedades de la instrucción en el código de fondo.

 bool isSALU = MI.getDesc().TSFlags & SIInstrFlags::SALU; 

Si la propiedad es más compleja, puede compararla con el valor descrito en TableGen, que se agregará a la enumeración generada automáticamente.

 (Desc.TSFlags & X86II::FormMask) == X86II::MRMSrcMem 


Predicados de funciones


Además, los predicados de función se pueden usar para obtener la información necesaria sobre las instrucciones. Con su ayuda, puede mostrar a TableGen que necesita generar una función que estará disponible en el código de fondo. La clase base con la que puede crear dicha definición de función se presenta a continuación.

 // Base class for function predicates. class FunctionPredicateBase<string name, MCStatement body> { string FunctionName = name; MCStatement Body = body; } 

Puede encontrar fácilmente ejemplos de uso en el backend para X86. Por lo tanto, existe su propia clase intermedia, con la ayuda de la cual ya se crean las definiciones necesarias de funciones.

 // Check that a call to method `Name` in class "XXXInstrInfo" (where XXX is // the name of a target) returns true. // // TIIPredicate definitions are used to model calls to the target-specific // InstrInfo. A TIIPredicate is treated specially by the InstrInfoEmitter // tablegen backend, which will use it to automatically generate a definition in // the target specific `InstrInfo` class. // // There cannot be multiple TIIPredicate definitions with the same name for the // same target class TIIPredicate<string Name, MCStatement body> : FunctionPredicateBase<Name, body>, MCInstPredicate; // This predicate evaluates to true only if the input machine instruction is a // 3-operands LEA. Tablegen automatically generates a new method for it in // X86GenInstrInfo. def IsThreeOperandsLEAFn : TIIPredicate<"isThreeOperandsLEA", IsThreeOperandsLEABody>; //   -    ,  -  ,       // Used to generate the body of a TII member function. def IsThreeOperandsLEABody : MCOpcodeSwitchStatement<[LEACases], MCReturnStatement<FalsePred>>; 

Como resultado, puede usar el método isThreeOperandsLEA en código C ++.

 if (!(TII->isThreeOperandsLEA(MI) || hasInefficientLEABaseReg(Base, Index)) || !TII->isSafeToClobberEFLAGS(MBB, MI) || Segment.getReg() != X86::NoRegister) return; 

Aquí TII es la información de instrucción de destino, que se puede obtener utilizando el método getInstrInfo() del MCSubtargetInfo para la arquitectura deseada.

Transformación de instrucciones durante optimizaciones. Mapeo de instrucciones


Durante una gran cantidad de optimizaciones realizadas en las etapas posteriores de la compilación, la tarea a menudo surge de convertir todas o solo una parte de las instrucciones de un formulario en instrucciones de otro formulario. Dada la aplicación de las multiclases descritas al principio, podemos tener una gran cantidad de instrucciones con semánticas y propiedades similares. En el código, estas transformaciones, por supuesto, podrían escribirse en forma de grandes construcciones de switch-case , que para cada instrucción aplastan la transformación correspondiente. Parcialmente, estas enormes construcciones se pueden reducir con la ayuda de macros, que formarían el nombre necesario de la instrucción de acuerdo con reglas bien conocidas. Pero aún así, este enfoque es muy inconveniente, es difícil de mantener debido al hecho de que todos los nombres de instrucciones se enumeran explícitamente. Agregar una nueva instrucción puede conducir fácilmente a un error, porque debe recordar agregarlo a todas las conversiones relevantes. Después de haber sido atormentado con este enfoque, LLVM creó un mecanismo especial para convertir eficientemente una forma de instrucción en otra Instruction Mapping .

La idea es muy simple, es necesario describir posibles modelos para transformar instrucciones directamente en TableGen. Por lo tanto, en LLVM TableGen hay una clase base para describir tales modelos.

 class InstrMapping { // Used to reduce search space only to the instructions using this // relation model. string FilterClass; // List of fields/attributes that should be same for all the instructions in // a row of the relation table. Think of this as a set of properties shared // by all the instructions related by this relationship. list<string> RowFields = []; // List of fields/attributes that are same for all the instructions // in a column of the relation table. list<string> ColFields = []; // Values for the fields/attributes listed in 'ColFields' corresponding to // the key instruction. This is the instruction that will be transformed // using this relation model. list<string> KeyCol = []; // List of values for the fields/attributes listed in 'ColFields', one for // each column in the relation table. These are the instructions a key // instruction will be transformed into. list<list<string> > ValueCols = []; } 

Veamos un ejemplo que se da en la documentación. Los ejemplos que se pueden encontrar en el código fuente ahora son aún más simples, ya que solo se obtienen dos columnas en la tabla final. En el código de back-end puede encontrar la conversión de formas antiguas a nuevas formas de instrucciones, instrucciones dsp en mmdsp, etc., descritas mediante el mapeo de instrucciones. De hecho, este mecanismo no se usa tan ampliamente hasta ahora, simplemente porque la mayoría de los backends comenzaron a crearse antes de que apareciera, y para que funcione, aún necesita establecer las propiedades correctas para las instrucciones, por lo que cambiar a él no siempre es fácil, puede necesitar algunos refactorización.

Entonces, por ejemplo. Supongamos que tenemos formas de instrucciones sin predicados e instrucciones donde el predicado es respectivamente verdadero y falso. Los describimos con la ayuda de una clase múltiple y una clase especial, que solo usaremos como filtro. Una descripción simplificada sin parámetros y muchas propiedades puede ser algo como esto.

 class PredRel; multiclass MyInstruction<string name> { let BaseOpcode = name in { def : PredRel { let PredSense = ""; } def _pt: PredRel { let PredSense = "true"; } def _pf: PredRel { let PredSense = "false"; } } } defm ADD: MyInstruction<”ADD”>; defm SUB: MyIntruction<”SUB”>; defm MUL: MyInstruction<”MUL”>; … 

En este ejemplo, por cierto, también se muestra cómo anular una propiedad para varias definiciones a la vez usando la construcción let … in . Como resultado, tenemos muchas instrucciones que almacenan su nombre base y propiedad que describe de forma única su forma. Entonces puedes crear un modelo de transformación.

 def getPredOpcode : InstrMapping { // ,       - PredRel  let FilterClass = "PredRel"; //         ,      let RowFields = ["BaseOpcode"]; //          PredSense. let ColFields = ["PredSense"]; //  ,  ,       ,     PredSense=”” let KeyCol = [""]; //   PredSense      let ValueCols = [["true"], ["false"]]; } 

Como resultado, la siguiente tabla se generará a partir de esta descripción.

PredSense = ""PredSense = "verdadero"PredSense = "falso"
AGREGARADD_ptADD_pf
SUBSUB_ptSUB_pf
MulMUL_ptMUL_pf

Se generará una función en el archivo .inc

 int getPredOpcode(uint16_t Opcode, enum PredSense inPredSense) 

Que, en consecuencia, acepta un código de instrucciones para la conversión y el valor de la enumeración generada automáticamente de PredSense, que contiene todos los valores posibles en las columnas. La implementación de esta función es muy simple, porque devuelve el elemento de matriz deseado para la instrucción que nos interesa.

Y en el código de back-end, en lugar de escribir un switch-case suficiente simplemente llamar a la función generada, que devolverá el código de la instrucción convertida. Una solución simple, donde agregar nuevas instrucciones, no conducirá a la necesidad de una acción adicional.

Artefactos generados automáticamente (archivos .inc )


Toda la interacción entre la descripción de TableGen y el código de fondo de LLVM está asegurada por los archivos .inc generados que contienen el código C. Para obtener una imagen completa, veamos un poco qué son exactamente.

Después de cada compilación, para cada arquitectura, habrá varios archivos .inc en el directorio de compilación, cada uno de los cuales almacena información separada sobre la arquitectura.Así que hay un archivo <TargetName>GenInstrInfo.incque contiene información sobre las instrucciones <TargetName>GenRegisterInfo.inc, respectivamente, que contiene información sobre los registros, hay archivos para trabajar directamente con el ensamblador y su salida <TargetName>GenAsmMatcher.incy <TargetName>GenAsmWriter.incetc.

Entonces, ¿en qué consisten estos archivos? En general, contienen enumeraciones, matrices, estructuras y funciones simples. Por ejemplo, puede ver la información convertida en las instrucciones en <TargetName>GenInstrInfo.inc.

En la primera parte, en el espacio de nombres con el nombre del objetivo hay una enumeración que contiene todas las instrucciones que se han descrito.

 namespace X86 { enum { PHI = 0, … ADD16i16 = 287, ADD16mi = 288, ADD16mi8 = 289, ADD16mr = 290, ADD16ri = 291, ADD16ri8 = 292, ADD16rm = 293, ADD16rr = 294, ADD16rr_REV = 295, … } 

El siguiente es un conjunto que describe las propiedades de las instrucciones const MCInstrDesc X86Insts[]. Las siguientes matrices contienen información sobre nombres de instrucciones, etc. Básicamente, toda la información se almacena en transferencias y matrices.

También hay funciones que se han descrito utilizando predicados. Según la definición del predicado de la función discutida en la sección anterior, se generará la siguiente función.

 bool X86InstrInfo::isThreeOperandsLEA(const MachineInstr &MI) { switch(MI.getOpcode()) { case X86::LEA32r: case X86::LEA64r: case X86::LEA64_32r: case X86::LEA16r: return ( MI.getOperand(1).isReg() && MI.getOperand(1).getReg() != 0 && MI.getOperand(3).isReg() && MI.getOperand(3).getReg() != 0 && ( ( MI.getOperand(4).isImm() && MI.getOperand(4).getImm() != 0 ) || (MI.getOperand(4).isGlobal()) ) ); default: return false; } // end of switch-stmt } 

Pero hay datos en los archivos y estructuras generados. En X86GenSubtargetInfo.incusted puede encontrar un ejemplo de la estructura que debe usarse en el código de back-end para obtener información sobre la arquitectura, a través de ella en la sección anterior obtuvimos TTI.

 struct X86GenMCSubtargetInfo : public MCSubtargetInfo { X86GenMCSubtargetInfo(const Triple &TT, StringRef CPU, StringRef FS, ArrayRef<SubtargetFeatureKV> PF, ArrayRef<SubtargetSubTypeKV> PD, const MCWriteProcResEntry *WPR, const MCWriteLatencyEntry *WL, const MCReadAdvanceEntry *RA, const InstrStage *IS, const unsigned *OC, const unsigned *FP) : MCSubtargetInfo(TT, CPU, FS, PF, PD, WPR, WL, RA, IS, OC, FP) { } unsigned resolveVariantSchedClass(unsigned SchedClass, const MCInst *MI, unsigned CPUID) const override { return X86_MC::resolveVariantSchedClassImpl(SchedClass, MI, CPUID); } }; 

Si se usa Subtargetpara describir varias configuraciones XXXGenSubtarget.inc, se creará una enumeración con las propiedades descritas usando SubtargetFeaturematrices con valores constantes para indicar las características y subtipos de la CPU, y se generará una función ParseSubtargetFeaturesque procesa la cadena con el conjunto de opciones Subtarget. Además, la implementación del método XXXSubtargeten el código de fondo debe corresponder al siguiente pseudocódigo, en el que es necesario usar esta función:

 XXXSubtarget::XXXSubtarget(const Module &M, const std::string &FS) { // Set the default features // Determine default and user specified characteristics of the CPU // Call ParseSubtargetFeatures(FS, CPU) to parse the features string // Perform any additional operations } 

A pesar de que los .incarchivos son muy voluminosos y contienen grandes matrices, esto nos permite optimizar el tiempo de acceso a la información, ya que el acceso a un elemento de matriz tiene un tiempo constante. Las funciones de búsqueda generadas por instrucciones se implementan utilizando un algoritmo de búsqueda binario para minimizar el tiempo de funcionamiento. Por lo tanto, el almacenamiento en esta forma está bastante justificado.

Conclusión


Como resultado, gracias a TableGen en LLVM, tenemos descripciones de arquitectura legibles y fácilmente compatibles en un solo formato con varios mecanismos para interactuar y acceder a la información del código fuente de back-end LLVM para optimizaciones y generación de código. Al mismo tiempo, dicha descripción no afecta el rendimiento del compilador debido al código autogenerado que utiliza soluciones eficientes y estructuras de datos.

Source: https://habr.com/ru/post/474460/


All Articles