Executar instruções em paralelo usando uma unidade de interface de barramento e uma unidade de execução é um caso especial de pipelining. A tradução de pipe line é oleoduto e, no sentido figurado, é fonte de informações. Obviamente, na informática, o termo é usado com o sentido de fonte de informações. Neste texto vou manter "pipeline" para designar fonte de informações e "pipelining" para designar a capacidade de obter informações. Bem, voltando à vaca fria, o 8486 incorpora pipelining para melhorar sua performance. Com apenas algumas poucas exceções, veremos que a pipelining permite executar uma instrução por ciclo de clock.
A vantagem trazida pela fila de pré-busca é que ela deixa a CPU sobrepor buscas e decodificações de instruções à execução destas instruções. Isto é, enquanto uma instrução está sendo executada, a BIU está buscando e decodificando a próxima instrução. Se partirmos do princípio de que podemos adicionar ainda mais um pouco de hardware, então poderemos executar praticamente todas as operações em paralelo. Esta é a idéia da pipelining.
Considere os passos necessários para realizar uma operação genérica:
![]() Fig.20 - "Mini Processador" |
Assumindo que estejamos dispostos a gastar um grana extra com silício, podemos construir um "mini-processador" que gerenciará cada uma das etapas acima citadas. A organização seria algo parecido com o mostrado na Fig.20.
Se projetarmos uma peça de hardware para cada estágio da pipeline, praticamente todos as etapas poderão ocorrer em paralelo. É claro que não poderemos buscar e decodificar o opcode de uma dada instrução simultaneamente, mas poderemos buscar um opcode enquanto a instrução anterior estiver sendo decodificada. Se tivermos uma pipeline de n-estágios, então poderemos ter n instruções sendo executadas simultaneamente. O 8486 é um processador que possui uma pipeline de seis estágios ou seja, é capaz de sobrepor a execução de seis instruções diferentes.
![]() Fig.21 - Execução de Instruções numa Pipeline |
A Fig.21, Execução de Instruções numa Pipeline, mostra uma pipelining. T1, T2, T3, etc, representam "tics" consecutivos do clock do sistema. Quando T=T1, a CPU busca o byte do opcode da primeira instrução.
Em T=T2, a CPU começa a decodificar o opcode da primeira instrução. Em paralelo, a pipeline busca os 16 bits da fila de busca antecipada caso a instrução tenha um operando. Como a primeira instrução não precisa mais dos circuitos de busca de opcodes, a CPU instrui a pipeline para buscar o opcode da segunda instrução, o que ocorre em paralelo com a decodificação da primeira. Observe que, neste ponto, há um pequeno conflito. A CPU está tentando buscar o próximo byte da fila de pré-busca para usá-lo como operando ao mesmo tempo em que a pipeline está buscando 16 bits da fila de pré-busca para usá-los como operando. As duas coisas podem ser feitas simultaneamente? Veremos a solução logo a seguir.
Em T=T3, a CPU calcula o endereço do operando da primeira instrução, se houver. A CPU nada faz na primeira instrução se esta não usar o modo de endereçamento [xxxx+bx]. Durante T3, a CPU também decodifica o opcode da segunda instrução e busca os operandos necessários. Finalmente, a CPU também busca o opcode da terceira instrução. A cada tic do clock, mais um passo da execução de cada instrução na pipeline é completado e a CPU busca outra instrução na memória.
Em T=T6, a CPU completa a execução da primeira instrução, calcula o resultado da segunda, etc, e, finalmente, busca o opcode da sexta instrução na pipeline. O importante é notar que, após T=T5, a CPU completa uma instrução a cada ciclo do clock. No momento em que a CPU preencher a pipeline, ela completa uma instrução em cada ciclo do clock. Observe que isto é verdadeiro mesmo se houver modos de endereçamento complexos que precisam ser calculados, operandos de memória que precisam ser buscados ou outras operações que usem ciclos num processador sem pipeline. Tudo que precisamos fazer é adicionar mais estágios à pipeline e continuar a processar cada instrução num único ciclo de clock.
Infelizmente o cenário apresentado no tópico anterior é simplista demais. Existem dois obstáculos nesta pipeline simples: contenção do barramento entre instruções e uma execução de programa não sequencial. Os dois problemas podem aumentar o tempo médio de execução das instruções na pipeline.
A conteção de barramento ocorre sempre que uma instrução precisar acessar algum item na memória. Por exemplo, se uma instrução mov mem, reg precisar armazenar dados na memória e uma instrução mov reg, mem estiver lendo dados da memória, deve ocorrer uma contenção nos barramentos de dados e de endereços porque a CPU tentará buscar e escrever dados na memória simultaneamente.
![]() Fig.22 - Baia na Pipeline |
Uma maneira simplista de lidar com a conteção de barramento é através de uma baia de pipeline. A CPU, quando confrontada com uma contenção, dá prioridade para a instrução que estiver mais adiantada na pipeline. A CPU suspende a busca de opcodes até que a instrução atual busque (ou armazene) seu operando. Isto faz com que a nova instrução na pipeline ocupe dois ciclos ao invés de um (veja a Fig.22).
Este exemplo é apenas um dos casos de conteção de barramento. Existem inúmeros outros. Por exemplo, como foi dito anteriormente, a busca de operandos de instrução necessita do acesso à fila de pré-busca no mesmo momento em que a CPU precisa buscar um opcode. Além disso, em processadores um pouco mais avançados que o 8486 (por exemplo, o 80486) existem outras fontes de contenção de barramento. De acordo com esquema simples da Fig.22, é pouco provável que a maioria das instruções sejam executadas a um clock por instrução (CPI = Clock Per Instruction).
Felizmente, o uso inteligente de um sistema cache pode eliminar muitas das baias de pipeline como as analisadas acima. O próximo tópico, sobre cache, descreverá como isto é feito. Entretanto, nem mesmo com um cache é possível prevenir baias na pipeline. Aquilo que não é possível consertar com hardware pode ser arrumado com software. Se evitarmos usar a memória, podemos reduzir a conteção de barramento e os programas serão mais rápidos. Da mesma forma, usando instruções mais curtas também reduz a contenção de barramento e a possibilidade do aparecimento de baias na pipeline.
O que acontece quando uma instrução modifica o registrador IP? No momento em que a instrução jmp 1000 for completada, já teremos começado outras cinco instruções e estamos a apenas um ciclo de clock distante do término da primeira delas. É óbvio que a CPU não precisa executar estas instruções, pois geraria resultados impróprios.
A única solução razoável é descarregar toda a pipeline e começar a buscar opcodes novamente. Entretanto, este procedimento causa uma severa perda de tempo de execução. Tomará seis ciclos de clock (o comprimento da pipeline do 8486) antes que a próxima instrução seja completada. Fica bem claro que precisamos evitar o uso de instruções que interrompem a execução sequencial de um programa. Isto também mostra um outro problema - o comprimento da pipeline. Quanto maior for a pipeline, mais pode ser realizado por ciclo no sistema. Entretanto, aumentando a pipeline pode tornar um programa lento se houver uma porção de desvios. Infelizmente não é possível controlar o número de estágios de uma pipeline. Pode-se, no entanto, controlar o número de instruções de transferência que aparecem num programa. Obviamente, num sistema com pipeline, seu uso deve ser mantido ao mínimo necessário.
Projetistas de sistemas podem resolver muitos problemas de contenção de barramento através do uso inteligente da fila de busca antecipada e do subsistema de memória cache. Podem projetar a fila de pré-busca para funcionar como buffer de dados do fluxo de instruções e podem projetar a cache com áreas distintas para dados e código. As duas técnicas podem melhorar a performance do sistema eliminando alguns conflitos de barramento.
A fila de busca antecipada simplesmente atua como um buffer entre o fluxo de instruções na memória e os circuitos de busca de opcodes. Infelizmente a fila de pré-busca do 8486 não apresenta a vantagem que apresentava no 8286. A fila funciona bem no 8286 porque a CPU não fica acessando a memória constantemente. Quando a CPU não está acessando a memória, a BIU pode buscar opcodes de instrução adicionais para a fila de pré-busca. No 8486, a CPU está constantemente acessando a memória porque ela busca um byte de opcode a cada ciclo de clock. Portanto, a fila de busca antecipada não pode tirar vantagem de qualquer ciclo de barramento "morto" para buscar bytes de opcodes adicionais simplesmente porque não existem ciclos "mortos" de barramento. Entretanto, a fila de pré-busca ainda tem valor no 8486 por uma razão muito simples: a BIU busca dois bytes em cada acesso à memória, acontece que algumas instruções possuem apenas um byte. Sem a fila de pré-busca, os sitema precisaria buscar todo e qualquer opcode, mesmo que a BIU já tivesse "acidentalmente" buscado o opcode junto com a instrução anterior. Com a fila de busca antecipada, no entanto, o sistema não precisará repetir a busca de qualquer opcode. Faz apenas uma busca e salva os bytes para serem usados pela unidade de busca de opcodes.
Por exemplo, se executarmos duas instruções de um byte seguidas, a BIU pode buscar os dois opcodes em um ciclo de memória, liberando o barramento para outras operações. A CPU pode usar estes ciclos de barramento para buscar opcodes adicionais ou para lidar com outros acessos à memória.
É claro que nem todas as instruções possuem apenas um byte. O 8486 possui dois tamanhos de instrução: um byte e três bytes. Se executarmos várias instruções de carga de três bytes seguidas, o programa ficará mais lento. Por exemplo:
mov ax, 1000 mov bx, 2000 mov cx, 3000 add ax, 5000
Cada uma destas instruções lê um byte de opcode e um operando de 16 bits (a constante), portanto, cada uma leva em média 1.5 ciclo de clock para ser lida. Como resultado, as instruções precisarão de seis ciclos de clock para serem executadas, ao invés de quatro.
Mais uma vez voltamos à mesma regra: os programas mais rápidos são aqueles que usam as instruções mais curtas. Se puder escolher instruções mais curtas para realizar determinada tarefa, faça-o. A seguinte sequência oferece um bom exemplo:
mov ax, 1000 mov bx, 1000 mov cx, 1000 add ax, 1000
Podemos reduzir o tamanho deste programa e aumentar a sua velocidade de execução mudando-o para:
mov ax, 1000 mov bx, ax mov cx, ax add ax, ax
Este código possui apenas cinco bytes ao invés dos 12 bytes do exemplo anterior. O código anterior precisará no mínimo de cinco ciclos de clock para ser executado e, se surgirem problemas de contenção de barramento, ainda mais. O último exemplo usa apenas quatro. Além do mais, o segundo exemplo libera o barramento em três dos quatro períodos de clock de modo que a BIU pode carregar opcodes adicionais. Lembre-se, mais curto com frequência significa mais rápido.
![]() Fig.23 - Máquina de Harvard |
Imagine, por um momento, que a CPU tenha dois espaços de memória separados, um para instruções e outro para dados, cada qual com seu próprio barramento. Esta é a chamada Arquitetura Harvard porque a primeira máquina deste tipo foi construída em Harvard. Numa máquina Harvard não haveria contenção de barramento e a BIU poderia continuar a busca de opcodes através do barramento de instruções enquanto acessasse a memória através do barramento de dados/memória.
No mundo real praticamente não existem máquinas Harvard verdadeiras. Os pinos extras que o processador precisa para oferecer dois barramentos fisicamente separados aumenta o custo do processador e introduz muitos outros problemas de engenharia. Entretanto, projetistas de microprocessadores descobriram que podem obter muitos dos benefícios da arquitetura Harvard com poucas das desvantagens usando caches on-chip separados para dados e instruções. CPUs avançadas usam uma arquitetura interna Harvard e uma arquitetura externa Von Neumann. A figura abaixo mostra a estrutura do 8486 com caches separados de dados e instruções.
![]() Fig.24 - Caches separadas no 8486 |
Cada caminho dentro da CPU representa um barramento independente. Os dados podem fluir em todos os barramentos concorrentemente. Isto significa que a fila de busca antecipada pode estar puxando opcodes de instrução da cache de instruções enquanto a unidade de execução estiver escrevendo dados na cache de dados. Agora a BIU busca opcodes na memória apenas quando não puder localizá-los na cache de instruções. De modo parecido, a cache de dados funciona como buffer de memória. A CPU usa o barramento dados/endereços apenas quando estiver lendo um dado que não encontrou na cache de dados ou quando estiver enviando dados de volta para a memória principal.
Aliás, o 8486 manipula o problema da contenção na busca de operandos de instrução/opcode de um jeito malandro. Ao adicionar um cirtuito decodificador extra, ele decodifica em paralelo a instrução no início da fila de pré-busca e mais três bytes da fila. Então, se a instrução anterior não exigiu um operando de 16 bits, a CPU usa o resultado do primeiro decodificador; se a instrução anterior usou um operando, a CPU usa o resultado do segundo decodificador.
Apesar de não podermos controlar a presença, tamanho ou tipo da cache numa CPU, como programadores de Assembly precisamos estar cientes de como a cache opera para podermos escrever os melhores programas. Caches de instrução on-chip geralmente são pequenas (8.192 bytes no 80486, por exemplo). Portanto, quanto mais curtas forem as instruções, mais delas cabem na cache (está se cansando das "instruções mais curtas"?). Quanto mais isntruções tivermos na cache, menos contenções de barramento irão ocorrer. Do mesmo modo, usando registradores para guardar resultados temporários faz com que menos grupos estejam na cache, evitando que ela tenha que enviar ou trazer dados da memória com muita frequência. Use os registradores sempre que possível!
Há um outro problema com o uso de uma pipeline: o perigo dos dados. Vamos dar uma olhada no perfil de execução da seguinte sequência de instruções:
mov bx, [1000] mov ax, [bx]
![]() Fig.25 - Aspecto da pipeline |
Quando estas duas instruções são executadas, a pipeline terá um aspecto parecido com o mostrado na Fig.25. Note um problema importante. Estas duas instruções buscam o valor de 16 bits cujo endereço aparece na localização 1000 da memória. Mas esta sequência de instruções não funciona direito! Infelizmente a segunda instrução já usou o valor de bx antes que a primeira carregasse o conteúdo da memória na localização 1000 (T4 e T6 na Fig.25).
![]() Fig.25a - Aspecto da pipeline com retardos |
Processadores CISC, como os 80x86, manipulam estes perigos automaticamente criando baias na pipeline para sincronizar as duas instruções. A execução do 8486 acaba sendo algo como mostrado na Fig.25a.
Atrasando a segunda instrução dois ciclos de clock, o 8486 garante que a instrução carregue ax do endereço apropriado. Infelizmente, agora a segunda instrução é executada em três ciclos de clock ao invés de um. No entanto, requerer dois ciclos de clock extras é melhor do que produzir resultados incorretos. Mas é possível reduzir o impacto destes perigos na velocidade de execução através de software.
Observe que o perigo dos dados ocorre quando o operando fonte de uma instrução era o operando destino da instrução anterior. Não há nada de errado carregar bx de [1000] e depois carregar ax de [bx], a não ser que uma instrução ocorra logo depois da outra. Imagine que a sequência tivesse sido:
mov cx, 2000 mov bx, [1000] mov ax, [bx]
Podemos reduzir o perigo que existe nesta sequência de código simplesmente rearranjando as instruções. Observe abaixo:
mov bx, [1000] mov cx, 2000 mov ax, [bx]
Neste caso a instrução mov ax precisa apenas de um ciclo de clock adicional ao invés de dois. Inserindo duas instruções entre as instruções mov bx e mov ax pode-se eliminar completamente os efeitos do perigo.
Num processador com pipeline, a ordem das instruções num programa pode afetar dramaticamente a performance deste programa. Verifique sempre se há possíveis perigos na sequência de instruções e elimine-os sempre que possível rearranjando as instruções.
Com a arquitetura de pipeline do 8486 obtivemos, na melhor das hipóteses, tempos de execução de um CPI (clock por instrução). Seria possível executar instruções mais rápido do que isto? À primeira vista parece não haver a mínima possibilidade de executar mais de uma instrução por ciclo de clock. Entretanto, lembre-se de que uma instrução única não é uma operação única. Nos exemplos mostrados anteriormente, cada instrução precisava de seis a oito operações para ser completada. Adicionando sete ou oito unidades à CPU poderíamos executar estas oito operações em um ciclo de clock e efetivamente obter um CPI. Se adicionarmos mais hardware e executarmos, digamos, 16 operações simultâneas, poderemos obter 0.5 CPI? A resposta é um sonoro "sim". Uma CPU que inclui este hardware adicional é uma CPU superescalar e pode executar mais de uma instrução durante um único ciclo de clock. Esta é a capacidade que o processador 8686 apresenta.
![]() Fig.26 - A CPU superescalar 8686 |
Uma CPU superescalar possui, essencialmente, várias unidades de execução. Se encontrar duas ou mais instruções no fluxo de instruções (isto é, na fila de busca antecipada) que possam ser executadas independentemente, ela o fará.
Existem uma porção de vantagens na arquitetura superescalar. Imagine as seguintes instruções no fluxo de instruções:
Se não houver outros problemas ou perigos no código circundante e se todos os seis bytes destas duas instruções já estiverem na fila de pré-busca, não existe motivo que impeça a CPU de buscar e executar as duas instruções em paralelo. Só é preciso um pouco de silício extra no chip da CPU para implementar duas unidades de execução.
Além de aumentar a velocidade de instruções independentes, uma CPU superescalar também aumenta a velocidade de sequências de programa que possuam perigos. Uma das limitações da CPU 8486 é que, quando ocorre uma situação de perigo, a instrução faltosa vai desarranjar completamente a pipeline com baias. Toda instrução seguinte também terá que esperar que a CPU sincronize a execução das instruções. Com uma CPU superescalar, no entanto, depois de uma situação de perigo, a execução das instruções seguintes pode continuar através da pipeline, contanto que elas mesmas não possuam perigos. Isto alivia (apesar de não eliminar) o cuidado que se precisa ter com a sequência das instruções.
Como programadores da linguagem Assembly, o modo como escrevemos o software para uma CPU superescalar pode afetar dramaticamente a sua performance. A primeira regra, e a mais importante, você já está careca de saber: usar instruções curtas. Quanto mais curtas forem as instruções, mais instruções a CPU pode buscar numa única operação. Portanto, a probabilidade da CPU executá-las em menos de um CPI aumenta. A maioria das CPUs superescalares não duplicam completamente a unidade de execução. Pode haver múltiplas ALUs, unidades de ponto flutuante, etc. Isto significa que certas sequências de instruções podem ser executadas com muita rapidez e outras não. É preciso estudar a composição exata de cada CPU para decidir quais são as sequências de instruções que resultam na melhor performance.
Gostou da pipeline, da cache e das CPUs superescalares? Aparentemente não há nada que não se resolva com alguns circuitos lógicos :)))
Bem, a família "de mentirinha" x86 foi apresentada e está à disposição para servir de base para nossos estudos. Porque se preocupar com uma família dessas? Porque não está longe da realidade, apenas foi simplificada um pouco. Esta simplificação nos ajuda a entender o "x" de muitas questões importantes.
Antes de partir para o laboratório do capítulo 3 só falta mais um tópico: como nossa família x86 se comunica com o mundo exterior. Então, para completar o tema 'organização de sistemas', vamos dar uma olhada nos dispositivos de entrada e saída.