Go Internals - Compilador
Introdução
Nove artigos depois, quase tudo que abrimos em Essentials ficou do lado do runtime: stacks e escape em Memória, mallocgc no Alocador, marcação tri-color no Garbage Collector, maps Swiss table em Tipos Nativos, dispatch via itab em Interfaces, reflect.Value sobre abi.Type em Reflection e parking de channels em Concorrência.
Este é o outro lado do mesmo mapa — o toolchain. Quando você roda go build, o cmd/compile não só emite instruções: decide o que escapa, quais calls somem dentro de callees, onde bounds checks podem cair e que metadados o GC e o scheduler precisam em cada frame. Os artigos de runtime assumem que esse trabalho já foi feito.
Seguimos no Go 1.26, lendo $GOROOT/src/cmd/compile/ junto com reproducers pequenos. O objetivo não é decorar nome de pass, e sim saber onde olhar quando profile ou saída de -gcflags apontam para o compilador em vez do runtime.
O que go build executa
go build é um orquestrador. Para um pacote comum, grosso modo:
flowchart LR
A[".go files"] --> B["compile (por arquivo)"]
B --> C[".o / archive"]
C --> D["link"]
D --> E["executável"]
Cada .go é compilado separadamente em object file. O linker (cmd/link) resolve símbolos, organiza dados read-only (descritores de tipo, itabs, strings), aplica relocações e puxa o runtime.
Arquivos assembly (.s) passam pelo cmd/asm. C no stdlib vai por cmd/cgo ou pelo C do platform quando necessário. Código de aplicação quase sempre segue só o caminho do compilador Go.
Dentro do cmd/compile, um arquivo percorre um pipeline longo mas estável. Nomes mudam entre releases; a forma se mantém há anos:
flowchart TB
P["parse"] --> TC["types2 typecheck"]
TC --> IR["noder → IR (representação intermediária)"]
IR --> W["walk (desugar)"]
W --> S["ssagen → SSA"]
S --> SP["SSA passes"]
SP --> G["genssa → machine code"]
G --> O["obj → object file"]
Parse e type check (types2) barram programas mal tipados antes de qualquer otimização. walk rebaixa construções que o backend não trata direto — range, certos switch, setup de select — para IR mais simples. O ssagen então rebaixa o IR para SSA, onde rodam a maior parte das otimizações orientadas a performance.
IR (representação intermediária): entre o source e o SSA
Depois do type check, noder em cmd/compile/internal/noder/ monta o IR do compilador — uma árvore tipada de nós (ir.Func, ir.AssignStmt, ir.CallExpr, …) que ainda espelha a sintaxe Go, mas já carrega tipos resolvidos e links de símbolos.
walk reescreve construções que o backend de SSA não consome direto. Um for range sobre slice vira loop indexado; certas formas de switch e select expandem para fluxo de controle mais simples. Escape analysis e alguns outros passes rodam nessa árvore antes do ssagen rebaixar cada função para SSA.
O IR é a última representação que ainda parece "Go com tipos colados". Depois do ssagen, você está em basic blocks e valores SSA — o grafo que prove, inlining e register allocation de fato reescrevem.
SSA: o middle end
Depois do ssagen, a função vive em forma Static Single Assignment em cmd/compile/internal/ssa/. Cada valor é atribuído uma vez; o fluxo de controle são blocos explícitos com predecessores e sucessores.
Essa representação é o que os passes de otimização consomem. O compilador prova fatos sobre intervalo de um valor, funde nil checks redundantes, reescreve loads/stores e apaga blocos mortos sem re-varrer uma AST mutável.
Um acesso a slice simplificado fica conceitualmente assim (SSA real é maior e varia por platform):
b1:
v1 = LocalAddr { .X }
v2 = Load v1
v3 = Const64 [0]
v4 = IsInBounds v2, v3 // len check
v5 = PtrIndex v1, v3
v6 = Load v5
...
Passes reescrevem esse grafo. prove pode aprender que v3 é sempre menor que v2, então v4 vira constante true e passes posteriores removem o branch. deadcode descarta blocos inalcançáveis. regalloc e genssa viram amd64/arm64/etc.
Não precisa ler cada arquivo de pass. Para debugar codegen, GOSSAFUNC (abaixo) dumpa o grafo após cada pass numa função — o jeito mais rápido de ver quem mudou o comportamento.
Inlining: pagar overhead de call adiantado
Overhead de call em Go é pequeno, mas existe: frame na stack, spill slots, às vezes write barriers em argumentos que escapam. Inlining copia o corpo do callee no caller quando o inliner em cmd/compile/internal/inline/ acha que vale.
Heurísticas pesam tamanho, recursão e se o callee tem //go:noinline. Wrappers minúsculos em hot path — helpers de sync/atomic, acessores de bytes — muitas vezes somem no call site.
func add(a, b int) int {
return a + b
}
func sum(xs []int) int {
total := 0
for _, x := range xs {
total = add(total, x) // provavelmente inlined
}
return total
}
Inlining interage com tudo. Corpo inlined expõe fatos de escape e bounds ao SSA do caller. Também cresce o frame do caller, o que pode impedir inlining dele — o budget do inliner é real.
Para ver decisões:
go build -gcflags="-m=2" ./...
Procure linhas como can inline add e inlining call to add. -m=2 traz budget e motivo quando uma função é rejeitada. Funções grandes, calls indiretos e ciclos recursivos são os bloqueios usuais.
//go:noinline e //go:inline (Go 1.22+) sobrescrevem defaults quando você está benchmarkando ou estabilizando stack trace. Use com parcimônia — brigam com a alavanca principal do compilador para tirar overhead de call site.
Escape analysis: a decisão de heap
Memória cobriu escape pelo lado da aplicação. Aqui o mesmo pass vive no pipeline: cmd/compile/internal/escape/ roda no IR antes ou junto da geração de SSA (o gancho exato muda; o grafo é o mesmo).
Escape analysis monta um grafo de atribuições e fluxo de ponteiros. Se um ponteiro para local pode sobreviver ao frame — return, store global, closure em go, send em channel, assign em interface{} — o compilador emite alocação no heap (newobject, newarray, etc.) em vez de slots na stack.
O runtime nunca "promove" variável de stack depois. Se -gcflags="-m" diz moved to heap, a alocação é incondicional em run time.
Inlining funde grafos entre funções. Callee que escaparia sozinho pode ficar na stack quando inlined num caller que não vaza o ponteiro — e o contrário também acontece. Por isso a saída de escape muda quando você refatora via helpers pequenos.
BCE (bounds-check elimination)
Go insere bounds checks em index de slice e array a menos que o compilador prove que são redundantes. O pass prove no SSA rastreia relações entre len, cap e índices.
func first(xs []int) int {
if len(xs) == 0 {
return 0
}
return xs[0] // prove: index 0 < len após o guard
}
func sum(xs []int) int {
total := 0
for i := 0; i < len(xs); i++ {
total += xs[i] // frequentemente BCE'd (bounds-check eliminated): i limitado por len(xs)
}
return total
}
Quando prove falha, você paga o check — compare e branch condicional a cada iteração no caso do loop. Loops quentes com checks ainda visíveis no assembly pedem reestruturação (bind early de len, for i := range xs separado, ou subs slice [:n] com len conhecido).
Inspeção de assembly:
go build -gcflags="-S" ./...
Busque helpers de bounds check (panicIndex, panicSliceB, etc.) no dump da função que importa. -d=ssa/prove/debug=1 (via GOSSAFUNC) mostra o que prove aprendeu quando -S não basta.
Devirtualization e calls de interface
Interfaces mostrou o caminho run-time via itab: carregar itab, carregar function pointer, call indireto. Quando o tipo concreto é conhecido em compile time, o compilador pode devirtualizar — chamar o método concreto direto ou inline — e pular lookup de itab.
type Counter interface {
Inc()
}
type N int
func (n *N) Inc() { *n++ }
func bump(c Counter) {
c.Inc() // pode devirtualizar quando callers de bump passam *N consistentemente
}
Devirtualization é frágil atravessando fronteiras de pacote e APIs interface-shaped. Código genérico e call sites com tipo concreto otimizam de forma mais previsível. Profile com go tool pprof — tempo em runtime.getitab ou calls indiretos em loop quente muitas vezes significa que o compilador perdeu o tipo concreto.
O que o compilador emite para o runtime
Machine code é metade do object file. A outra metade são metadados que o runtime lê sem interpretar Go source.
Stack maps
O GC precisa achar ponteiros em todo frame de stack com goroutines rodando. O compilador grava stack maps — bitmaps ou listas de slots dizendo quais words na stack são ponteiros em cada safe point (call, back-edge de loop, entry de função).
É assim que o Garbage Collector scaneia stacks em concorrência sem adivinhação conservadora. Se um slot não está marcado live no map, o GC ignora mesmo que os bits pareçam endereço.
Stack maps também alimentam cópia de stack em Memória: quando newstack copia a stack de uma goroutine, usa os mesmos maps para ajustar ponteiros internos.
Descritores de tipo
Todo tipo concreto usado em run time ganha descritor abi.Type (rodata). O linker deduplica; reflection e interfaces leem as mesmas tabelas (Reflection, Interfaces).
O compilador emite isso a partir do IR tipado — layouts de campo, bitmaps de ponteiro para GC, listas de método, equal/hash onde precisa. Operações de map e channel referenciam descritores para tamanho de elemento e pointer-ness; Tipos Nativos mostrou o lado runtime.
Write barriers
Quando o Garbage Collector roda em concorrência, stores do mutator em ponteiros de heap precisam de write barriers. O compilador insere calls de barrier (ou sequências fundidas) em stores de ponteiro que podem criar novas arestas no grafo do heap.
Nem todo store leva barrier — stores em slots de stack, stores nil e alguns alvos conhecidamente non-heap são pulados. Escape analysis importa aqui também: menos ponteiros no heap muitas vezes significa menos sites de barrier em hot path.
Stack checks e morestack
Todo prólogo de função compara stack pointer com o guard slot que o runtime mantém. Falha salta para morestack, que pode chamar newstack (Memória).
Esse check é inserido pelo compilador; a lógica de crescimento é runtime. O split aparece claro em profiles: runtime.morestack sem allocs correspondentes no heap em geral significa recursão profunda ou frames grandes, não pressão de GC.
Tabelas de panic e defer
defer, panic e recover compilam para tabelas e calls de runtime (deferproc, _panic, etc.) em vez de exceções zero-cost. O compilador organiza quais defers pertencem a qual função e onde fazer unwind.
defer pesado em loop apertado ainda aparece em profile por isso — não porque defer esteja "quebrado", mas porque o fast path é call compilado para a cadeia de defer do runtime.
Observando o compilador
| Flag / env | O que você obtém |
|---|---|
-gcflags="-m" |
Decisões de escape e resumo de inlining |
-gcflags="-m=2" |
Mais detalhe do porquê inline falhou |
-gcflags="-S" |
Listagem de assembly por função |
GOSSAFUNC=pkg.Func go build |
Dump de SSA após cada pass numa função |
-gcflags="-d=ssa/check/on" |
Sanity checks de SSA (dev do compilador; lento) |
Rode isso em pacote pequeno ou arquivo único. Build de módulo inteiro gera páginas de saída.
Fluxo de exemplo para um loop suspeito:
GOSSAFUNC=main.sum go build -gcflags="-S" .
Compare SSA antes e depois de prove, depois leia o assembly em busca de panicIndex sobrando. Cruze com escape via -gcflags="-m" se allocs aparecerem sem fonte óbvia.
Compilador vs runtime: quem cuida do quê
| Pergunta | Respondido em compile time | Respondido em run time |
|---|---|---|
| Stack ou heap para um local? | escape analysis | — |
| Index de slice seguro? | BCE (bounds-check elimination) / prove |
panic se check restou e falhou |
| Qual método numa interface? | devirtualization quando possível | itab + call indireto |
| Scheduling de goroutine | — | scheduler (Scheduler) |
| Wait / wake de channel | emite calls chansend/chanrecv |
Concorrência |
| Quando memória de heap é reclaimada? | emite alocação + barriers | GC |
| Stack pequena demais? | insere stack check | morestack / newstack |
Debug em produção costuma pular entre as duas colunas. alloc_space em profile aponta para escape do compilador; tempo em syscall aponta para scheduler e netpoller; profiles de mutex apontam para sync que vimos em Concorrência — mas call sites e escolhas de inlining vieram daqui.
Conclusão
O compilador Go é a equipe de advance do runtime. Passes de SSA remodelam suas funções em grafos sobre os quais dá para provar coisas; inlining, escape analysis, bounds-check elimination e devirtualization decidem se hot paths ficam enxutos; stack maps, descritores de tipo, barriers e stack checks entram no binário para a maquinaria do resto da série funcionar.
Isso fecha Go Internals. Você tem agora o corte vertical completo — de go build até scheduling de goroutine, memória, GC, representações da linguagem, sync e o toolchain que amarra tudo. Quando o Go 1.27 mover um pass ou renomear struct, abra os mesmos caminhos em $GOROOT/src e diff contra o que estes artigos descrevem; o mapa ainda deve levar você à camada certa.