Módulo 06: Pipeline — Conceitos e Implementação Básica — Resumo

Esta é a versão de revisão do Módulo 06, para você reler na véspera da prova ou antes de mexer no benchmark do Projeto Integrador. As demonstrações completas, os exemplos extras e as deduções passo a passo estão em 06_material.qmd e, com ainda mais fôlego, em 06_livro.qmd. Aqui eu vou direto ao que precisa ficar na sua cabeça.

Um processador a 1 GHz tem ciclo de clock de um nanosegundo, e nesse tempo um sinal elétrico mal atravessa o próprio chip. Ainda assim a máquina conclui bilhões de instruções por segundo. Como? A resposta curta é que ela não executa uma instrução inteira por vez. Ela mantém várias andando ao mesmo tempo, cada uma numa etapa diferente — e esse truque, o pipeline, já rodava silenciosamente no PIC18F4550 que você programou no Módulo 05. O objetivo deste módulo é sair da intuição e chegar ao número: quanto desempenho o pipeline entrega, por que o caso real fica abaixo do ideal, e como medir isso no Timer0 do seu kit.

A ideia do pipeline: sobrepor trabalho

Lembra da lavanderia? Lavar, secar e dobrar quatro cargas, trinta minutos cada etapa. Do jeito ingênuo, uma carga inteira antes da próxima: noventa minutos por carga, e a lavadora parada enquanto a primeira seca. O jeito esperto é botar a segunda carga para lavar assim que a primeira sai para a secadora. As três estações trabalham ao mesmo tempo, cada uma numa carga. A primeira ainda leva noventa minutos, mas a partir daí sai uma carga pronta a cada trinta. Guarde esses dois números: noventa (uma carga isolada) e trinta (o intervalo entre cargas prontas). Eles voltam com nome formal.

flowchart LR
    subgraph Sequencial["Sem sobreposicao (6 h)"]
        direction LR
        L1["Lavar C1"] --> S1["Secar C1"] --> D1["Dobrar C1"] --> L2["Lavar C2"]
    end
    subgraph Pipeline["Com sobreposicao (3 h)"]
        direction TB
        T1["t=30: Lavar C1"]
        T2["t=60: Secar C1<br/>Lavar C2"]
        T3["t=90: Dobrar C1<br/>Secar C2<br/>Lavar C3"]
        T1 --> T2 --> T3
    end
    Sequencial -. mesma latencia<br/>menor throughput .-> Pipeline
Figura 1: A sobreposição não acelera uma carga isolada; aumenta a taxa com que cargas saem prontas.

No processador é a mesma coisa, só que mais rápido. As cargas são as instruções; as estações são os estágios, blocos de hardware que fazem uma fração do processamento. A cada borda de clock, todas as instruções avançam um estágio de uma vez. Aqui mora a confusão mais comum, e eu mesmo demorei a sacar: num pipeline de cinco estágios, dizer que cinco instruções estão “em execução simultânea” não significa cinco instruções por ciclo. Cada instrução ainda leva cinco ciclos do começo ao fim. O que acontece ao mesmo tempo é o trabalho de estágios diferentes sobre instruções diferentes. Nenhuma anda mais rápido; o que aumenta é a frequência com que elas saem prontas pela ponta.

Tem uma condição escondida: para dois estágios trabalharem juntos, eles precisam de hardware físico distinto. O estágio de busca usa a porta da memória de programa; o de acesso a dados usa a porta da memória de dados. Se houvesse uma única memória com uma única porta, os dois colidiriam — e essa colisão é o hazard estrutural do Módulo 07. Não é coincidência que arquiteturas com pipeline gostem da separação física entre instruções e dados, a organização Harvard que você reconhece de cara no PIC18F4550.

O pipeline clássico de cinco estágios

Por que cinco? Porque é o menor número que separa bem as tarefas de uma arquitetura load-store: buscar usa a memória de programa; decodificar e ler operandos usa o banco de registradores; calcular usa a ULA; acessar dados usa a memória de dados; escrever de volta usa o banco outra vez. Cinco tarefas, cada uma com seu recurso predominante. O número não é sagrado — o PIC usa dois, os desktops dos anos 2000 passaram de vinte — mas virou o modelo didático canônico, herdado do MIPS dos anos 1980.

flowchart LR
    IF["IF<br/>Busca<br/>memoria de programa"] --> R1[["Reg IF/ID"]]
    R1 --> ID["ID<br/>Decodifica<br/>le registradores"]
    ID --> R2[["Reg ID/EX"]]
    R2 --> EX["EX<br/>ULA<br/>calcula e decide desvio"]
    EX --> R3[["Reg EX/MEM"]]
    R3 --> MEM["MEM<br/>Acesso<br/>memoria de dados"]
    MEM --> R4[["Reg MEM/WB"]]
    R4 --> WB["WB<br/>Escreve<br/>banco de registradores"]
Figura 2: Os cinco estágios e os quatro registradores de pipeline entre eles.

O IF (busca) lê a instrução apontada pelo PC e já o incrementa — no PIC, de dois, porque cada instrução ocupa duas posições de byte. Repara na aposta otimista: o IF precisa saber qual instrução buscar antes de a anterior ser decodificada, então aposta que é a do endereço seguinte. Vale quase sempre, porque programa é quase sempre sequencial, mas falha num desvio tomado. O ID (decodificação) interpreta o opcode e lê os operandos no banco. O EX (execução) é onde a ULA do Módulo 03 calcula e onde a condição do desvio é resolvida — num pipeline de cinco estágios, dois ciclos depois de o desvio entrar, o que gera penalidade de dois ciclos. O MEM acessa a memória de dados (só cargas e armazenamentos fazem algo útil aqui; as demais atravessam vazias, de propósito, para todas terem o mesmo número de estágios). O WB escreve o resultado de volta no banco, e aí a instrução sai do pipeline.

Acompanha com lápis: cinco instruções, I1 a I5. No ciclo 1, só I1 em IF. No ciclo 3, I1 em EX, I2 em ID, I3 em IF. No ciclo 5 o pipeline está cheio, com uma instrução em cada estágio, e a partir daí sai uma instrução pronta por ciclo. Os quatro ciclos iniciais de enchimento, em que nada conclui, são o transiente que pesa só em programas curtos.

flowchart TB
    subgraph C1["Ciclo 1"]
        a1["I1: IF"]
    end
    subgraph C2["Ciclo 2"]
        a2["I1: ID"]
        b2["I2: IF"]
    end
    subgraph C3["Ciclo 3"]
        a3["I1: EX"]
        b3["I2: ID"]
        c3["I3: IF"]
    end
    subgraph C5["Ciclo 5 (cheio)"]
        a5["I1: WB"]
        b5["I2: MEM"]
        c5["I3: EX"]
        d5["I4: ID"]
        e5["I5: IF"]
    end
    C1 --> C2 --> C3 --> C5
    a5 -. uma conclusao por ciclo .-> Saida["I1 concluida"]
Figura 3: Enchimento e regime permanente: a partir do ciclo 5, uma conclusão por ciclo.

Registradores de pipeline e temporização

A lavanderia esconde uma dificuldade física: roupa fica onde foi posta, sinal elétrico não. Os fios que saem do IF são os que alimentam o ID; se nada os separasse, ao buscar a próxima instrução o IF sobrescreveria o que o ID ainda usa. A solução é pôr, entre cada par de estágios, um banco de registradores que captura os sinais na borda do clock e os segura estáveis durante o ciclo seguinte. Num pipeline de cinco estágios são quatro deles, e cada um carrega dados e também os sinais de controle ainda não usados — eles viajam pelo pipeline como passageiros que descem em pontos diferentes.

Esses registradores impõem a regra que governa a velocidade do chip. A cada ciclo, o sinal precisa atravessar a lógica de um estágio e chegar estável à entrada do registrador antes da próxima borda. Sendo t_i o atraso da lógica do estágio i e t_{reg} o atraso dos registradores, o período mínimo é

T_{clk} \geq \max_{1 \le i \le k}\,(t_i) + t_{reg}.

O estágio que atinge esse máximo é o gargalo, e é só ele que manda na frequência máxima. Não adianta quatro estágios velozes e um lento: o clock se acomoda ao lento e os outros desperdiçam o tempo que sobra, em todo ciclo. Por isso projetistas suam para balancear os estágios.

E aí vem a tentação: se o gargalo é o estágio mais lento, por que não picar tudo em pedaços menores, aumentando k e encurtando o ciclo? Dois freios. O próprio t_{reg}, que se soma a cada registrador novo e a certa altura passa a dominar o ciclo. E, mais sério, pipelines profundos sofrem penalidades maiores quando a aposta da busca falha — assunto do Módulo 07. Profundidade é parâmetro com retornos decrescentes, não botão que se gira à vontade.

Latência e throughput: a distinção que organiza tudo

Aqui está o conceito que, se sobrar um só na sua memória, quero que seja esse. Diante de um processador dá para fazer duas perguntas que parecem iguais e não são. Quanto tempo leva uma instrução do início ao fim? Isso é latência. Quantas instruções saem por segundo em regime permanente? Isso é throughput. Num pipeline de k estágios com período T_{clk}, a latência sem stalls é L = k \cdot T_{clk} e o throughput com o pipeline cheio é \Theta = 1 / T_{clk}, uma instrução por ciclo. A latência cresce com o número de estágios; o throughput não depende dele, só do período. É isso que resolve o paradoxo da abertura: o pipeline não acelera nenhuma instrução — dividir em mais estágios até aumenta um pouco a latência —, ele melhora o throughput ao encurtar o ciclo.

Pense no seu projeto: o sistema lê um sensor e atualiza o display LCD Sunstar 2004A. Se o usuário aperta um botão de emergência no pino RB0 e dispara uma interrupção, o que importa para a reação rápida — o throughput da rotina de fundo ou a latência do tratamento? Trocar um pelo outro leva a um sistema que processa muito mas demora a reagir.

Quando um fabricante diz que o processador “executa k vezes mais rápido” por causa de um pipeline de k estágios, ele fala de throughput em condições ideais, nunca da latência de uma instrução isolada. Em carga real, com desvios e dependências, o ganho efetivo é sempre menor.

Formalizando o desempenho: CPI, speedup e o ganho ideal

Para calcular ganho com rigor, parto da equação que liga o tempo total às grandezas mensuráveis:

T_{exec} = N_{instr} \cdot \text{CPI} \cdot T_{clk}.

São três alavancas para reduzir o tempo: menos instruções (compilador e ISA), menor período de clock (tecnologia e balanceamento) e menor CPI (organização interna). É no CPI que o pipeline mete a mão.

flowchart LR
    T["Tempo de execucao"] --> N["N de instrucoes<br/>compilador e ISA"]
    T --> CPI["CPI<br/>organizacao e pipeline"]
    T --> CLK["Periodo de clock<br/>tecnologia e gargalo"]
    CPI --> P["Penalidades (p)<br/>desvios tomados<br/>instrucoes de 2 palavras"]
Figura 4: As três alavancas do tempo de execução e onde o pipeline atua.

Sem pipeline, cada instrução percorre k etapas inteiras antes da próxima começar, e o CPI é k. Com pipeline ideal, uma instrução conclui por ciclo e o CPI tende a 1 — é essa queda de k para 1 que dá o ganho. Mas desconfie de “ideal”: o CPI igual a 1 supõe que o pipeline nunca esvazia, que nenhuma instrução depende de resultado de outra ainda em voo e que nenhum desvio quebra o fluxo. Nada disso vale plenamente em programa real, então o CPI efetivo é sempre maior que 1. Comparando uma máquina sem pipeline com uma pipelined de k estágios, para N instruções o speedup teórico é

S = \frac{N \cdot k}{N + k - 1}, \qquad \lim_{N \to \infty} S = k.

O ganho de até k vezes só aparece em programas longos. Com k=5, cinco instruções dão S \approx 2{,}78; cem instruções, S \approx 4{,}81; dez mil, praticamente 5. Nos laços do seu firmware, que rodam milhões de vezes, o enchimento some na contabilidade. O ganho real fica abaixo de k por desbalanceamento dos estágios, pelo sobrecusto t_{reg} e, sobretudo, pelos hazards. Modelando a penalidade média por p ciclos por instrução,

\text{CPI}_{ef} = 1 + p, \qquad S_{ef} = \frac{k}{1 + p}.

Essa última é a fórmula que você vai usar no projeto: medindo o speedup efetivo e conhecendo k, você inverte e estima p. Mas cuidado com um erro fino — reduzir o CPI nem sempre reduz o tempo, porque o tempo depende também de N_{instr}. Ao inlinar uma sub-rotina você baixa o CPI mas pode aumentar o número de instruções. Olhe sempre o produto inteiro N_{instr} \cdot \text{CPI}, nunca um fator isolado.

O pipeline do PIC18F4550 na prática

Hora de aterrissar no chip que está na sua mão. O PIC18F4550 não tem cinco estágios; tem dois, busca e execução. Essa simplicidade é boa para você, porque deixa observar os fenômenos do pipeline sem o emaranhado de cinco. A 48 MHz, o ciclo de máquina é

T_{cy} = \frac{4}{48\ \text{MHz}} = 83{,}3\ \text{ns},

um throughput de pico de doze MIPS para instruções de um ciclo. Esse é o teto que seu benchmark vai tentar alcançar. A aposta da busca falha sempre que o fluxo deixa de ser sequencial: num desvio tomado, a instrução já pré-buscada é descartada e gasta-se um ciclo extra buscando a do destino — a bolha de pipeline. E repara na assimetria: um desvio condicional cuja condição é falsa não custa nada, porque a instrução pré-buscada era mesmo a certa. Essa é a base daquela otimização do Módulo 05 — organizar o código para que o caso frequente caia no ramo não tomado.

flowchart TB
    subgraph Continuo["Fluxo sequencial: 1 instrucao por ciclo"]
        p1["Busca I2<br/>Executa I1"] --> p2["Busca I3<br/>Executa I2"] --> p3["Busca I4<br/>Executa I3"]
    end
    subgraph Desvio["Desvio tomado em I3: 1 ciclo de bolha"]
        q1["Busca I4 (errada)<br/>Executa I3 = desvio"] --> q2["Busca destino<br/>BOLHA: sem execucao util"] --> q3["Executa destino"]
    end
    Continuo -. desvio tomado quebra o prefetch .-> Desvio
Figura 5: No PIC18F4550, cada desvio tomado custa um ciclo de bolha; o não tomado não custa nada.

A maioria das instruções gasta um ciclo; desvios tomados e instruções de duas palavras (saltos absolutos, chamadas) gastam dois. Sendo f_d a fração dessas, o CPI do PIC é \text{CPI}_{ef} = 1 + f_d. Código puramente sequencial dá f_d = 0 e CPI igual a 1; quanto mais desvios tomados, mais o CPI degrada.

A primeira tarefa do projeto pede um benchmark do throughput efetivo. Use o Timer0 como cronômetro: sem prescaler, ele incrementa uma vez por ciclo de máquina, então a diferença entre a leitura final e a inicial é o número exato de ciclos. Três cuidados separam medição confiável de enganosa: leia TMR0L antes de TMR0H, porque o hardware só trava o byte alto quando você lê o baixo; desconte o sobrecusto cronometrando um trecho vazio; e repita o trecho um número grande e conhecido de vezes, dividindo a contagem. O código abaixo lê o Timer0 e devolve a contagem de ciclos.

#include <xc.h>
#include <stdint.h>

uint16_t mede_ciclos(void) {
    uint8_t lo = TMR0L;      // lê o baixo primeiro
    uint8_t hi = TMR0H;      // depois o alto (travado)
    return ((uint16_t)hi << 8) | lo;
}
; retorna em PRODH:PRODL a contagem do Timer0
MedeCiclos:
    movf    TMR0L, w  ; lê baixo -> trava alto
    movwf   PRODL
    movf    TMR0H, w  ; lê alto travado
    movwf   PRODH
    return

De posse da contagem C de um trecho com N_{instr} instruções, o throughput efetivo é \Theta = N_{instr} / (C \cdot T_{cy}) e o CPI efetivo é C / N_{instr}. Confrontar esse CPI medido com o previsto pela fração de desvios é o coração do exercício: quando batem, você entendeu o modelo; quando divergem, investigar a divergência ensina mais que a coincidência. A segunda tarefa pede a visualização do fluxo pelo pipeline, e eu insisto que você desenhe os primeiros diagramas à mão — instruções na vertical, ciclos na horizontal, cada instrução deslocada um ciclo à direita, começando pelo modelo de dois estágios do PIC. Quem só usa a ferramenta nunca cria a intuição de prever em que ciclo cada instrução estará. A terceira tarefa pede identificar as instruções que causam stalls ou bolhas: fluxo sequencial e desvios não tomados custam um ciclo sem bolha; desvios tomados e instruções de duas palavras custam dois, e o segundo é a bolha.

Síntese: da linha de montagem ao Timer0

Voltando ao paradoxo: a máquina conclui várias instruções no tempo de um sinal porque não as executa uma por vez — mantém várias em estágios diferentes, aumentando o throughput sem reduzir a latência de cada uma. Tudo o mais foi elaboração disso: a equação T_{exec} = N_{instr} \cdot \text{CPI} \cdot T_{clk}, o speedup ideal que tende a k, e o efetivo S_{ef} = k/(1+p) que sempre fica abaixo por causa das penalidades. As três tarefas do projeto saem daqui: o benchmark cronometrado por Timer0 com o modelo \text{CPI}_{ef} = 1 + f_d, a visualização do fluxo à mão, e a análise de stalls e bolhas. E fica a promessa aberta: ao derivar o ganho ideal, supus que o pipeline nunca para, que nenhuma instrução depende de outra em voo, que nenhum desvio quebra o fluxo. Tudo falso em programa real, e é o desmoronar dessas suposições que produz os hazards do Módulo 07.