En los artículos anteriores, hemos explorado cómo el compilador de Go procesa tu código: el scanner lo divide en tokens, el parser construye un Abstract Syntax Tree, el comprobador de tipos lo valida todo, y el formato Unified IR serializa el AST con tipos en una representación binaria compacta.
Ahora llegamos a un punto crítico de transformación. El compilador toma ese Unified IR—ya sea recién serializado desde tu código o cargado desde un archivo de caché—y lo deserializa directamente en nodos IR. Aquí es donde tu código fuente se convierte realmente en el formato de trabajo del compilador.
El IR no es simplemente otra representación de tu código—es una representación optimizada para lo que viene después: análisis y transformación. El compilador necesita responder preguntas que son difíciles de contestar desde el AST:
- ¿Puede esta asignación quedarse en el stack, o debe ir al heap?
- ¿Se puede reemplazar esta llamada de interfaz con una llamada directa?
- ¿Se puede hacer inline de esta función?
- ¿Qué variables nunca se usan?
El AST es perfecto para representar lo que escribiste—archivos, declaraciones, sentencias que reflejan la estructura de tu código. Pero el compilador necesita algo diferente: código organizado por unidades de compilación, con tipos de operaciones explícitas, información de tipos embebida, y todas las operaciones implícitas hechas visibles.
Esa es la Representación Intermedia, o IR.
El formato Unified IR que vimos en el artículo anterior es la forma serializada—la codificación binaria compacta que va en los archivos ar. Lo que veremos ahora es la forma en memoria—las estructuras de datos reales que el compilador manipula durante la optimización y generación de código. El proceso de deserialización en src/cmd/compile/internal/noder/reader.go transforma el formato binario directamente en estos nodos IR, dándole al compilador el formato de trabajo que necesita.
Veamos cómo son estos nodos IR y en qué se diferencian de lo anterior.
¿Qué es el IR?
El IR organiza el código por paquete, no por archivo. Los archivos son un artefacto de cómo organizaste tu código—al compilador le importan los paquetes como unidades de compilación. Así se ve la estructura de paquete (de src/cmd/compile/internal/ir/package.go:9-42):
type Package struct {
Imports []*types.Pkg // Imported packages
Inits []*Func // Package init functions
Funcs []*Func // Top-level functions
Externs []*Name // Package-level declarations
AsmHdrDecls []*Name // Assembly declarations
CgoPragmas [][]string // Cgo directives
Embeds []*Name // Variables with //go:embed
PluginExports []*Name // Exported plugin symbols
}
Esta estructura nos dice qué le importa realmente al compilador a nivel de paquete: ¿de qué otros paquetes depende este paquete (Imports)? ¿Qué funciones de inicialización deben ejecutarse antes que nada (Inits)? ¿Cuáles son todas las funciones (Funcs) y las declaraciones a nivel de paquete (Externs)? Todo lo demás—el código de implementación real—vive dentro de estas estructuras de nivel superior. Los cuerpos de las funciones, por ejemplo, se almacenan dentro de los nodos Func.
Dentro de esos cuerpos de función, el IR representa cada operación como un nodo. Cada nodo tiene un código de operación (u “op”) que identifica qué tipo de operación representa. Hay unos 150 tipos diferentes de operaciones:
OADD- operación de sumaOIF- sentencia ifOCALL- llamada a funciónOCONVIFACE- conversión a interfazOLITERAL- valor literal- Y muchos más…
Estos códigos de operación son los bloques de construcción del IR. Cada expresión, cada sentencia, cada operación en tu código Go se representa como un nodo con un código de operación específico. Los nodos forman una estructura de árbol, igual que el AST, pero con diferencias clave que los optimizan para las necesidades del compilador.
Estructura de nodos: cómo se construyen
Cada nodo IR empieza con la misma base—un miniNode (src/cmd/compile/internal/ir/mini.go:16-87):
type miniNode struct {
pos src.XPos // source position
op Op // operation type (OADD, OIF, OCALL, etc.)
bits bitset8 // flags (typecheck status, walked)
esc uint16 // escape analysis result
}
Cuatro piezas esenciales: de dónde viene esto (pos), qué operación es (op), algunos flags de estado (bits), y el resultado del análisis de escape (esc).
A partir de aquí, los nodos se dividen en dos categorías. Las expresiones producen valores, así que añaden información de tipos:
type miniExpr struct {
miniNode // embedded base
typ *types.Type // type information
init Nodes // initialization statements
flags bitset8 // expression-specific flags
}
Las sentencias realizan acciones sin producir valores, así que no necesitan el campo de tipo:
type miniStmt struct {
miniNode // embedded base
init Nodes // initialization statements
}
Luego las operaciones específicas se construyen sobre estas. ¿Operaciones binarias como x + y? Eso es un BinaryExpr:
type BinaryExpr struct {
miniExpr // embedded expression fields
X Node // left operand
Y Node // right operand
}
¿Sentencias if? Eso es IfStmt:
type IfStmt struct {
miniStmt // embedded statement fields
Cond Node // condition expression
Body Nodes // statements when true
Else Nodes // statements when false
}
Este diseño por capas lo mantiene eficiente—los campos comunes viven en la base, los nodos específicos solo añaden lo que necesitan. Ahora veamos qué hace realmente el compilador con estos nodos.
El pipeline de optimización
Una vez construido el IR, el compilador ejecuta varias optimizaciones a nivel de IR—antes de convertir a SSA. Las optimizaciones clave son:
- Devirtualización: Convertir llamadas indirectas (métodos de interfaz) en llamadas directas cuando se conoce el tipo concreto
- Inlining: Reemplazar llamadas a funciones con el cuerpo de la función
- Análisis de escape: Determinar si los valores pueden quedarse en el stack o deben escapar al heap
- Eliminación de locales muertas: Eliminar asignaciones a variables locales no usadas
Lo más interesante es que la devirtualización y el inlining se ejecutan entrelazados—iteran juntos hasta que no hay más optimizaciones posibles. La devirtualización habilita el inlining, y el inlining puede exponer más oportunidades de devirtualización.
Empecemos con la devirtualización.
Devirtualización: convirtiendo lo indirecto en directo
La devirtualización es el proceso de convertir llamadas indirectas (llamadas a métodos de interfaz) en llamadas directas cuando se conoce el tipo concreto.
¿Por qué importa esto? Tres razones:
- Inlining: Las llamadas directas se pueden hacer inline; las indirectas no
- Mejores optimizaciones: Las llamadas directas tienen efectos secundarios conocidos
- Menor overhead: Sin dispatch virtual ni búsqueda de interfaz
Veámoslo en acción.
Devirtualización estática
Considera este código:
type Processor interface {
Process(x int) int
}
type SimpleProcessor struct{}
func (s SimpleProcessor) Process(x int) int {
return x * 2
}
func compute(p Processor, x int) int {
return p.Process(x) // Interface call
}
La llamada p.Process(x) pasa por el mecanismo de dispatch de interfaz—tu programa busca el método en la tabla de métodos de la interfaz en tiempo de ejecución. Esto es más lento y no se puede hacer inline.
Pero el compilador a menudo puede demostrar cuál es el tipo concreto. Cuando llamas a compute así:
func main() {
proc := SimpleProcessor{}
result := compute(proc, 42) // Concrete type visible!
}
El compilador rastrea hacia atrás a través de las conversiones y ve: “¡Ah, p es realmente un SimpleProcessor!” Entonces devirtualiza la llamada:
func compute(p Processor, x int) int {
// Devirtualized: type assertion inserted (no runtime check)
return (SimpleProcessor(p)).Process(x) // Direct call!
}
Esta transformación ocurre en src/cmd/compile/internal/devirtualize/devirtualize.go. El algoritmo es directo:
- Encontrar la llamada de interfaz (operación
OCALLINTER) - Rastrear hacia atrás hasta donde se estableció el valor de la interfaz
- Extraer el tipo concreto de esa asignación
- Reemplazar la llamada de interfaz con una llamada directa al método
El OCALLINTER (llamada de interfaz) se convierte en OCALLMETH (llamada a método)—una llamada directa que ahora se puede hacer inline.
Pero el compilador tiene otro truco de devirtualización bajo la manga.
Devirtualización Profile-Guided (PGO)
La devirtualización estática solo funciona cuando el compilador puede demostrar el tipo concreto. ¿Pero qué pasa si el tipo varía en tiempo de ejecución?
Ahí es donde entra la Profile-Guided Optimization (PGO). Con PGO, el compilador usa datos de perfil en tiempo de ejecución para identificar hot call sites e inserta devirtualización condicional:
// Original:
func process(i Processor) {
i.Process() // Hot call site, profile shows 95% SimpleProcessor
}
// After PGO devirtualization:
func process(i Processor) {
if concrete, ok := i.(SimpleProcessor); ok {
concrete.Process() // Direct call - fast path!
} else {
i.Process() // Fallback - slow path
}
}
El fast path es una llamada directa que se puede hacer inline. El slow path maneja el otro 5% de casos. ¿El resultado? Aceleraciones masivas en los hot paths.
Esto ocurre en src/cmd/compile/internal/devirtualize/pgo.go. El algoritmo:
- Consultar el perfil para este call site
- Encontrar el hottest callee (mayor número de ejecuciones)
- Generar una aserción de tipo y rama condicional
- Hacer inline del fast path si es posible
La clave: la devirtualización PGO solo se aplica cuando el fast path se puede hacer inline. De lo contrario, el overhead del condicional supera el beneficio.
Ya hemos mencionado el inlining varias veces. Veamos cómo funciona realmente.
Inlining de funciones: eliminando llamadas
El inlining de funciones reemplaza una llamada a función con el cuerpo de la función. Esta es una optimización muy potente del compilador:
// Before:
func add(x, y int) int {
return x + y
}
func compute() int {
return add(5, 10)
}
// After inlining:
func compute() int {
return 5 + 10 // Function body copied, call eliminated
}
Eliminar la llamada es solo el principio. El inlining expone el cuerpo de la función al contexto del llamador, habilitando:
- Propagación de constantes: El compilador ve
5 + 10y lo pliega a15 - Eliminación de variables no usadas: Los parámetros y locales que no se necesitan se vuelven obvios
- Más devirtualización: Los tipos concretos se vuelven visibles
- Asignación en el stack: Variables que escaparían ahora pueden quedarse en el stack
Pero el inlining tiene un coste: aumenta el tamaño del código. Si haces inline demasiado agresivamente, tu binario se hincha. El compilador necesita una estrategia para decidir de qué hacer inline.
El modelo de coste
Go usa un sistema de presupuesto basado en nodos. Cada función tiene un “presupuesto” de nodos que puede contener y seguir siendo candidata a inline:
| Tipo de presupuesto | Valor | Cuándo se aplica |
|---|---|---|
| Por defecto | 80 nodos | La mayoría de funciones |
| Closure llamada una vez | 800 nodos | Closures de un solo uso (¡10×!) |
| PGO hot function | 2000 nodos | Hot functions identificadas por perfil (¡25×!) |
El compilador recorre el árbol IR de la función y cuenta nodos. La mayoría de operaciones cuestan 1 nodo. Algunas son más caras:
- Llamadas a función: 57 nodos (¡caras!)
- Llamadas de interfaz: 57 nodos
- Llamadas a funciones candidatas a inline conocidas: Usan el coste real de esa función
- Llamadas a panic: Casi gratis (1 nodo)
Aquí está la parte inteligente: si una función llama a otra función candidata a inline, el compilador usa el coste de la función que está siendo llamada en lugar del coste genérico de llamada. Esto premia construir programas con funciones pequeñas y componibles.
Entonces, ¿cómo ocurre realmente el inlining? Es un proceso de dos fases.
El proceso de inlining
Primero, el compilador determina qué es candidato a inline:
Fase 1: Análisis (CanInline)
El compilador determina si cada función es candidata a inline:
func CanInline(fn *ir.Func, profile *pgoir.Profile) {
// Check hard constraints (go:noinline pragma, etc.)
if reason := InlineImpossible(fn); reason != "" {
return
}
// Calculate cost by walking the IR tree
visitor := hairyVisitor{
budget: inlineBudget(fn, profile),
}
visitor.visitList(fn.Body)
// Store result
if visitor.budget >= 0 {
fn.Inl = &ir.Inline{
Cost: initialBudget - visitor.budget,
}
}
}
El “hairiness visitor” recorre el árbol IR, decrementando el presupuesto por cada nodo. Si el presupuesto llega a cero, la función es “too hairy” (demasiado compleja) para inline.
Una vez que el compilador sabe qué se puede hacer inline, es hora de hacerlo realmente.
Fase 2: Transformación (TryInlineCall)
En los call sites, el compilador decide si hacer inline:
func mkinlcall(call *ir.CallExpr, fn *ir.Func) ir.Node {
// Copy function body
body := ir.DeepCopy(fn.Inl.Body, inlvars)
// Create InlinedCallExpr
res := ir.NewInlinedCallExpr(...)
res.Body = body
return res
}
El cuerpo de la función se copia en el llamador, con los parámetros reemplazados por los argumentos. La llamada a add(5, 10) se reemplaza con un nodo InlinedCallExpr que contiene el cuerpo de la función—con x e y vinculados a 5 y 10. La llamada desaparece, y las sentencias de la función se convierten en parte del llamador.
Pero el sistema de presupuesto es solo el principio. El compilador se ha vuelto mucho más listo sobre cuándo hacer inline.
Heurísticas avanzadas
El compilador usa heurísticas sofisticadas para decisiones más inteligentes de inlining (implementadas en src/cmd/compile/internal/inline/inlheur/scoring.go). Más allá del tamaño de la función, mira el contexto de cada call site y ajusta la puntuación arriba o abajo basándose en qué optimizaciones podría desbloquear el inlining.
Algunos ajustes desalientan el inlining aumentando la puntuación. Si una llamada está en un panic path—código que lleva incondicionalmente a panic o salida—el compilador añade 40 puntos para desalentar el inlining. ¿Por qué? Los panic paths rara vez se ejecutan, así que hacer inline de ellos desperdicia espacio de código. Similarmente, las llamadas en funciones init() se penalizan porque init se ejecuta una vez al inicio; el inlining proporciona un beneficio mínimo.
Otros ajustes alientan el inlining disminuyendo la puntuación. Las llamadas dentro de bucles obtienen un pequeño impulso (-5) porque se ejecutan repetidamente—el inlining ahorra el overhead de llamada muchas veces.
Los ajustes más interesantes involucran parámetros y valores de retorno. El compilador analiza qué estás pasando a la función y qué devuelve, buscando oportunidades de optimización. ¿Pasando un tipo concreto que se convierte a una interfaz para una llamada a método? Eso es -30 puntos—el inlining habilita la devirtualización. ¿Pasando una constante que alimenta una sentencia if? El compilador sabe que el inlining habilitará la eliminación de ramas.
Hay unos 14 ajustes diferentes organizados en tres categorías: basados en contexto (dónde está la llamada), basados en parámetros (qué estás pasando), y basados en valor de retorno (qué hace el llamador con el resultado). El compilador no solo mira el tamaño de la función—predice las oportunidades de optimización que el inlining desbloqueará.
También hay un mecanismo de seguridad para funciones grandes. Cuando una función crece más allá de 5000 nodos, el compilador la considera “grande” y se vuelve mucho más conservador sobre hacer inline en ella. En estas funciones grandes, el compilador solo hará inline de funciones que cuesten 20 nodos o menos. Esto previene que funciones ya grandes exploten en tamaño y mantiene los tiempos de compilación razonables.
Ahora es donde la devirtualización y el inlining realmente hacen su magia juntos.
La estrategia entrelazada
La devirtualización y el inlining se ejecutan juntos en un bucle hasta que no hay más optimizaciones posibles:
digraph InterleaveStrategy {
rankdir=LR;
node [shape=box, style=rounded];
Devirt [label="Devirtualización"];
Inline [label="Inlining"];
Check [label="¿Más cambios?", shape=diamond];
Done [label="Análisis de escape"];
Devirt -> Inline;
Inline -> Check;
Check -> Devirt [label="Sí"];
Check -> Done [label="No"];
}
El bucle es simple: la devirtualización convierte llamadas indirectas en directas, el inlining expone esos cuerpos de función y revela más tipos concretos, luego el compilador comprueba si se hicieron cambios. Si sí, ejecuta otra ronda—los tipos recién expuestos podrían habilitar más devirtualización. Si no hubo cambios, hemos alcanzado un punto fijo y pasamos al análisis de escape.
Te muestro por qué importa esto con un ejemplo concreto:
type Processor interface {
Process(int) int
}
func helper(p Processor, x int) int {
return p.Process(x) // Interface call
}
func compute(p Processor, x int) int {
return helper(p, x) // Call to helper
}
func main() {
proc := ConcreteProcessor{}
result := compute(proc, 42)
}
Iteración 1:
Estado inicial en main:
result := compute(proc, 42)
Procesar compute(proc, 42):
- Devirtualizar: No aplica (llamada directa)
- Inline: ✓ Inline de
compute→ exponehelper(p, x)
Después de Iteración 1:
result := helper(proc, 42) // New call discovered!
Iteración 2:
Procesar helper(proc, 42):
- Devirtualizar: No aplica (llamada directa)
- Inline: ✓ Inline de
helper→ exponep.Process(x)
Después de Iteración 2:
var p Processor = proc // OCONVIFACE
result := p.Process(42) // OCALLINTER - New interface call!
Iteración 3:
Procesar p.Process(42):
- Devirtualizar: ✓
devirtualize.StaticCallveOCONVIFACE→ cambiaOCALLINTERaOCALLMETH - Inline: Si
Processes suficientemente pequeña, ¡hacer inline también!
Después de Iteración 3:
result := ConcreteProcessor.Process(proc, 42) // Direct call
// OR if Process inlines:
result := 42 * 2 // Inlined body of Process
Iteración 4:
- No se encontraron cambios
- El bucle sale (punto fijo alcanzado)
Sin entrelazado, no podríamos hacer inline de la llamada al método devirtualizado. Con entrelazado, eliminamos tres llamadas a función haciendo inline y devirtualizando repetidamente hasta que no quedaron más oportunidades.
Esta estrategia entrelazada está implementada en src/cmd/compile/internal/inline/interleaved/interleaved.go. Es el corazón del optimizador IR de Go.
Con las llamadas optimizadas y los cuerpos de función expuestos, el compilador ahora tiene que tomar una decisión crucial sobre cada variable: ¿dónde debería vivir?
Análisis de escape: ¿heap o stack?
Después de la devirtualización y el inlining, el compilador ejecuta el análisis de escape. Esto determina si las variables pueden asignarse en el stack o deben asignarse en el heap.
El stack es una región de memoria que crece y decrece con las llamadas a función. Cada función obtiene un “stack frame” que contiene sus variables locales. Cuando la función retorna, ese frame se elimina—liberando instantáneamente toda su memoria. La asignación en el stack es simplemente mover un puntero; es increíblemente rápida y no requiere limpieza.
El heap es una región de memoria para datos que necesitan vivir más allá de una sola llamada a función. Asignar en el heap es más lento—implica encontrar espacio libre y requiere que el GC lo limpie después. Pero el heap es necesario cuando los datos necesitan sobrevivir a la función que los creó.
Veamos esta diferencia en acción:
func foo() *int {
x := 42 // Escapes: address returned
return &x // Must be on heap!
}
func bar() int {
x := 42 // Doesn't escape
return x // Can stay on stack
}
Mira bar primero. La variable x se crea, se usa, y luego su valor (42) se devuelve. Una vez que bar termina, x desaparece—nadie la necesita ya. El stack frame desaparece, y eso está bien. La asignación en el stack es perfecta aquí: rápida y limpiada automáticamente.
Ahora foo. Aquí devolvemos &x—un puntero a x. El llamador usará este puntero para acceder a x después de que foo retorne. Pero aquí está el problema: si x viviera en el stack, ese stack frame se destruiría cuando foo retorna. El puntero apuntaría a memoria que ha sido reclamada, potencialmente sobrescrita por la siguiente llamada a función. Eso es un puntero colgante—comportamiento indefinido clásico.
Así que el compilador mueve x al heap. La memoria del heap persiste después de que la función retorna. El puntero permanece válido, y el recolector de basura limpiará x después cuando nadie lo esté usando. La variable escapa del ámbito de la función—necesita sobrevivir a la llamada a función.
Pero detectar estos casos en código real no es tan directo como nuestros ejemplos simples. El compilador necesita una forma sistemática de rastrear dónde fluye cada valor.
Cómo funciona
El compilador necesita responder una pregunta para cada variable: ¿puede quedarse en el stack, o debe ir al heap?
Aquí hay un ejemplo simple:
func process() *Data {
x := Data{value: 10}
y := &x
z := y
return z
}
El compilador rastrea a través de este código, siguiendo a dónde va x:
Paso 1: Mira qué se devuelve. Estamos devolviendo z. Eso es un puntero, y está saliendo de la función—quien llamó a process() lo usará. Así que lo que sea que z apunte debe sobrevivir después de que process() retorne.
Paso 2: ¿De dónde viene z? Se asigna desde y. Así que lo que sea que y apunte también debe sobrevivir.
Paso 3: ¿De dónde viene y? Es la dirección de x (&x). Así que y apunta a x, lo que significa que x debe sobrevivir—tiene que sobrevivir a la función.
Conclusión: x debe ir al heap. Si ponemos x en el stack, desaparecería cuando process() retorna, y el puntero que devolvimos apuntaría a memoria basura.
Aquí está la clave: el compilador trabaja hacia atrás. Empieza desde los puntos de escape obvios (retornos, variables globales, valores pasados a funciones desconocidas) y rastrea hacia atrás a través de asignaciones para encontrar qué más debe escapar. El compilador sigue rastreando hacia atrás hasta que lo ha comprobado todo. Al final, cada variable está marcada: stack o heap. Estas marcas permanecen adjuntas a las variables durante el resto de la compilación—cuando el compilador genera el código máquina final, usará estas marcas para decidir si asignar cada variable en el stack o llamar al asignador del heap.
Después de todo este inlining y optimización, usualmente queda algo de basura—variables que se copiaron pero nunca se usaron realmente. Tiempo para un último pase de limpieza.
Limpiando variables locales muertas
La optimización final a nivel IR es la eliminación de locales muertas. Después de todo ese inlining, a menudo hay variables por ahí que se copiaron al contexto del llamador pero nunca se usaron realmente. La eliminación de locales muertas las encuentra y elimina.
El compilador recorre tu código con una estrategia simple: asumir que todo está muerto hasta que se demuestre lo contrario. Cuando ve una asignación como x := 10, registra esa asignación como “potencialmente muerta”. Cuando ve que esa variable se usa en algún sitio—y := x + 5—marca la variable como “definitivamente viva”. Al final del escaneo, cualquier variable que aún esté marcada “potencialmente muerta” tiene su asignación eliminada.
Aquí está la comprobación de seguridad importante: el compilador solo elimina asignaciones donde el lado derecho no tiene efectos secundarios. Asignaciones como x := 42 o x := otraVariable se pueden eliminar de forma segura si x nunca se usa—son solo números o lecturas. Pero x := funcionCara() permanece incluso si x no se usa, porque llamar a esa función podría hacer trabajo importante: escribir a un archivo, enviar una petición de red, modificar estado global.
Este pase es el equipo de limpieza—elimina la basura dejada después de que el inlining y la devirtualización fusionan funciones. Variables que servían un propósito en la función original a menudo son redundantes en el contexto fusionado, y la eliminación de locales muertas las barre.
Eso cubre las principales optimizaciones a nivel de IR. Estos pases trabajan juntos—cada uno prepara oportunidades para el siguiente—transformando tu código Go de alto nivel en algo mucho más eficiente antes de que incluso llegue a la fase SSA.
Resumen
El IR es donde ocurre parte de la magia del compilador. Después de que tu código es parseado y se comprueban los tipos, el IR se convierte en el formato de trabajo para la optimización.
Hemos cubierto las principales optimizaciones a nivel de IR: la devirtualización convierte llamadas de interfaz en llamadas directas, el inlining copia cuerpos de función para eliminar llamadas y exponer más oportunidades de optimización, y estas dos se ejecutan juntas en un bucle hasta que no hay más cambios posibles. El análisis de escape marca variables para asignación en el stack o heap. La eliminación de locales muertas elimina asignaciones a variables no usadas (cuando es seguro).
Estas optimizaciones forman una cascada—cada una prepara oportunidades para la siguiente. El resultado es código que es dramáticamente más eficiente de lo que escribiste, mientras tú escribes Go idiomático de alto nivel.
¿Quieres ver estas optimizaciones en acción en tu propio código? El flag -gcflags='-m' le dice al compilador de Go que imprima las decisiones de optimización sobre devirtualización, inlining y análisis de escape. El flag tiene múltiples niveles que controlan la cantidad de detalle:
-m: Muestra devirtualización, decisiones de inlining y análisis de escape-m=2: Información más detallada (incluyendo por qué cosas no se hicieron inline)-m=3: Aún más detalles-m=4: Máxima verbosidad
Prueba go build -gcflags='-m' tu_archivo.go para ver las decisiones básicas de optimización, o usa -m=2 para obtener más detalle incluyendo por qué ciertas funciones no se hicieron inline. El compilador imprimirá cada decisión que toma.
En el próximo artículo de esta serie, cubriré la fase SSA—donde el IR se convierte a la forma Static Single Assignment y pasa por optimizaciones aún más sofisticadas.
