Voltar ao início

Go Internals - Compilador

11 min de leitura
Cover Image for Go Internals - Compilador
Lucas LemosLucas Lemos

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.