240 57 45MB
Portuguese Pages 1312
Organização e Projeto de Computadores Interface Hardware/Software TRADUÇÃO DA 5ª EDIÇÃO
David A. Patterson University of California, Berkeley
John L. Hennessy Stanford University Com a colaboração de Perry Alexander The University of Kansas Peter J. Ashenden Ashenden Designs Pty Ltd Jason D. Bakos
University of South Carolina Javier Bruguera Universidade de Santiago de Compostela Jichuan Chang Hewlett-Packard Matthew Farrens University of California, Davis David Kaeli Northeastern University Nicole Kaiyan University of Adelaide David Kirk NVIDIA James R. Larus School of Computer and Communications Science at EPFL Jacob Leverich Hewlett-Packard Kevin Lim Hewlett-Packard John Nickolls NVIDIA John Oliver Cal Poly, San Luis Obispo Milos Prvulovic Georgia Tech
Partha Ranganathan Hewlett-Packard Tradução Daniel Vieira
Sumário Capa Folha de rosto Copyright Dedicatória Agradecimentos Prefácio 1: Abstrações e Tecnologias Computacionais 1.1. Introdução 1.2. Oito grandes ideias sobre arquitetura de computadores 1.3. Por trás do programa 1.4. Sob as tampas 1.5. Tecnologias para construção de processadores e memórias 1.6. Desempenho 1.7. A barreira da potência 1.8. Mudança de mares: Passando de processadores para multiprocessadores
1.9. Vida real: Fabricação e benchmarking do Intel Core i7 1.10. Falácias e armadilhas 1.11. Comentários finais 1.12. Exercícios
2: Instruções: A Linguagem dos Computadores Os cinco componentes clássicos de um computador
3: Aritmética Computacional Os cinco componentes clássicos de um computador
4: O Processador Os cinco componentes clássicos de um computador
5: Grande e Rápida: Explorando a Hierarquia de Memória Os cinco componentes clássicos de um computador
6: Processadores paralelos do cliente à nuvem Organização de multiprocessador ou cluster
Apêndice A: Assemblers, Link-editores e o Simulador SPIM Apêndice B: Fundamentos do Projeto Lógico Índice MIPS Guia de Referência
Copyright Do original Computer Organization and Design: The Hardware/Software Interface, 5th edition. Tradução autorizada do idioma inglês da edição publicada por Morgan Kaufmann Publishers, an imprint of Elsevier. Copyright © 2014 Elsevier Inc. All rights reserved © 2017, Elsevier Editora Ltda. Todos os direitos reservados e protegidos pela Lei n° 9.610, de 19/02/1998. Nenhuma parte deste livro, sem autorização prévia por escrito da editora, poderá ser reproduzida ou transmitida sejam quais forem os meios empregados: eletrônicos, mecânicos, fotográficos, gravação ou quaisquer outros. ISBN original: 978-0-12-407726-3 ISBN: 978-85-352- 8793-6 ISBN (versão digital): 978-85-352- 8794-3 Copidesque: Augusto Coutinho Revisão: Elaine dos S. Batista Editoração Eletrônica: Thomson Digital Elsevier Editora Ltda. Conhecimento sem Fronteiras Edifício City TowerRua da Assembleia, n° 100 – 6° andar – Sala 601 20011-904 – Centro – Rio de Janeiro – RJ Rua Quintana, 753 – 8° andar 04569-011 – Brooklin – São Paulo – SP Serviço de Atendimento ao Cliente
Serviço de Atendimento ao Cliente 0800-0265340 [email protected]
Nota Muito zelo e técnica foram empregados na edição desta obra. No entanto, podem ocorrer erros de digitação, impressão ou dúvida conceitual. Em qualquer das hipóteses, solicitamos a comunicação ao nosso Serviço de Atendimento ao Cliente, para que possamos esclarecer ou encaminhar a questão. Nem a editora nem o autor assumem qualquer responsabilidade por eventuais danos ou perdas a pessoas ou bens, originados do uso desta publicação. CIP-BRASIL. CATALOGAÇÃO NA PUBLICAÇÃO SINDICATO NACIONAL DOS EDITORES DE LIVROS, RJ P344o 5. ed. Patterson, David Organização e projeto de computadores / David Patterson, John L. Hennessy. - 5. ed. - Rio de Janeiro : Elsevier, 2017. il. Tradução de: Computer organization and design Inclui bibliografia e índice ISBN: 978-85-352-8793-6 1. Sistemas operacionais (Computação). I. Hennessy, John L. II. Título. 17-42533 CDD: 005.43 CDU: 004.451
Dedicatória
Para Linda, que foi, é, e sempre será o amor da minha vida.
David A. Patterson leciona arquitetura de computadores na University of California, Berkeley, desde que ingressou na faculdade em 1977, onde é diretor de Ciência da Computação. Seu trabalho como professor lhe rendeu o Distinguished Teaching Award da University of California, o Karlstrom Award da ACM, a Mulligan Education Medal e o Undergraduate Teaching Award do IEEE. Patterson recebeu o IEEE Technical Achievement Award e o ACM Eckert-Mauchly Award por suas contribuições ao RISC, e compartilhou o IEEE Johnson Information Storage Award pelas contribuições ao RAID. Também compartilhou a IEEE John von Neumann Medal e o C&C Prize com John Hennessy. Como seu coautor, Patterson é Fellow da American Academy of Arts and Sciences, do Computer History Museum, ACM e IEEE, e foi eleito para a National Academy of Engineering, a National Academy of Sciences e o Silicon Valley Engineering Hall of Fame. Trabalhou no Information Technology Advisory Committee para o presidente dos Estados Unidos, como presidente da divisão de Ciência da Computação no departamento EECS, em Berkeley, como presidente da Computing Research Association, e como presidente da ACM. Esse registro o fez receber o Distinguished Service Awards da ACM e da CRA. Em Berkeley, Patterson liderou o projeto e a implementação do RISC I, provavelmente o primeiro computador VLSI com conjunto de instruções reduzido, e a base comercial da arquitetura SPARC. Foi líder do projeto Redundant Arrays of Inexpensive Disks (RAID), que levou aos sistemas de armazenamento confiáveis de muitas empresas. Também esteve envolvido no projeto Network of Workstations (NOW), que levou à tecnologia de cluster usada por empresas da Internet e, mais tarde, à computação em nuvem. Esses projetos receberam três prêmios de dissertação da ACM. Seus projetos de pesquisa atuais são Algoritmo Homem-Máquina e Algoritmos e Especializadores para Implementações Provavelmente Ideais com Resiliência e Eficiência. O AMP Lab está desenvolvendo algoritmos de aprendizado de máquina escaláveis, modelos de programação amigáveis ao computador em escala de warehouse e ferramentas de crowdsourcing para obter rapidamente percepções valiosas, a partir de big data na nuvem. O ASPIRE Lab utiliza o ajuste profundo de hardware e software para alcançar os mais altos níveis de desempenho e eficiência de energia possíveis para sistemas de computação móveis e em escala de rack. John L. Hennessy é o décimo presidente da Stanford University, de onde é
membro desde 1977, nos departamentos de engenharia elétrica e ciência da computação. Hennessy é Fellow do IEEE e da ACM; membro da National Academy of Engineering, da National Academy of Science e da American Philosophical Society; e Fellow da American Academy of Arts and Sciences. Entre seus muitos prêmios estão o 2001 Eckert-Mauchly Award por suas contribuições à tecnologia RISC, o 2001 Seymour Cray Computer Engineering Award e o 2000 John von Neumann Award, em conjunto com David Patterson. Também obteve sete doutorados honorários. Em 1981, iniciou o projeto MIPS, em Stanford com alguns poucos alunos de graduação. Depois de concluir o projeto em 1984, afastou-se da universidade para ser o cofundador da MIPS Computer Systems (atualmente MIPS Technologies), que desenvolveu um dos primeiros microprocessadores comerciais RISC. Em 2006, mais de 2 bilhões de microprocessadores MIPS haviam sido instalados em dispositivos, desde videogames e computadores palmtop até impressoras a laser e switches de rede. Mais tarde, Hennessy liderou o projeto DASH (Director Architecture for Shared Memory), que produziu o protótipo do primeiro multiprocessador com coerência de cache escalável; muitas de suas principais ideias foram adotadas nos multiprocessadores modernos. Além de suas atividades técnicas e responsabilidades na universidade, continuou a trabalhar com diversas startups, como conselheiro no estágio inicial ou como investidor.
Agradecimentos Figuras 1.7, 1.8 Cortesia da iFixit (www.ifixit.com). Figura 1.9 Cortesia da Chipworks (www.chipworks.com). Figura 1.13 Cortesia da Intel. Figuras 1.10.1, 1.10.2, 4.15.2 Cortesia de Charles Babbage Institute, University of Minnesota Libraries, Minneapolis. Figuras 1.10.3, 4.15.1, 4.15.3, 5.12.3, 6.14.2 Cortesia da IBM. Figura 1.10.4 Cortesia da Cray Inc. Figura 1.10.5 Cortesia da Apple Computer, Inc. Figura 1.10.6 Cortesia do Computer History Museum. Figuras 5.17.1, 5.17.2 Cortesia do Museum of Science, Boston. Figura 5.17.4 Cortesia da MIPS Technologies, Inc. Figura 6.15.1 Cortesia da NASA Ames Research Center.
Prefácio O que podemos experimentar de mais belo é o misterioso. Ele é a fonte de toda arte e ciência verdadeiras. Albert Einstein, Como vejo o mundo, 1930
Sobre este livro Acreditamos que o aprendizado na Ciência da Computação e na Engenharia deve refletir o estado atual da área, além de apresentar os princípios que estão moldando a computação. Também achamos que os leitores em cada especialidade da computação precisam apreciar os paradigmas organizacionais que determinam as capacidades, o desempenho e, por fim, o sucesso dos sistemas computacionais. A tecnologia computacional moderna exige que os profissionais de cada especialidade da computação entendam tanto o hardware quanto o software. A interação entre hardware e software em diversos níveis também oferece uma estrutura para se entender os fundamentos da computação. Não importa se seu interesse principal é hardware ou software, Ciência da Computação ou Engenharia Elétrica, as ideias centrais na organização e projeto de computadores são as mesmas. Assim, nossa ênfase neste livro é mostrar o relacionamento entre hardware e software e apresentar os conceitos que são a base para os computadores atuais. A passagem recente do processador único para microprocessadores multicore confirmou a solidez desse ponto de vista, dado desde a primeira edição. Embora os programadores pudessem ignorar o aviso e confiar em arquitetos de computador, escritores de compilador e engenheiros de silício para fazer os seus programas executarem mais rápido ou com menos energia, sem mudanças, essa era já terminou. Para os programas executarem mais rápido, eles precisam se tornar paralelos. Embora o objetivo de muitos pesquisadores seja possibilitar que os programadores não precisem saber a natureza paralela subjacente do hardware que estão programando, serão necessários muitos anos para se concretizar essa visão. Nossa visão é que, pelo menos na próxima década, a maioria dos programadores terá de entender a interface hardware/software se quiser que os programas executem de modo eficiente em computadores paralelos. Este livro é útil para aqueles com pouca experiência em linguagem assembly ou projeto lógico, que precisam entender a organização básica do computador, e também para leitores com base em linguagem assembly e/ou projeto lógico, que queiram aprender a projetar um computador ou entender como um sistema funciona e por que se comporta de determinada forma.
Sobre o outro livro Alguns leitores podem estar familiarizados com Arquitetura de Computadores: Uma abordagem quantitativa, conhecido popularmente como Hennessy e Patterson. (Este livro, por sua vez, é chamado Patterson e Hennessy.) Nossa motivação ao escrever aquele livro foi descrever os princípios da arquitetura de computadores usando os fundamentos sólidos de engenharia e a relação quantitativa custo/benefício. Usamos um enfoque que combinava exemplos e medições, baseado em sistemas comerciais, para criar experiências de projeto realísticas. Nosso objetivo foi demonstrar que arquitetura de computadores poderia ser aprendida por meio de metodologias quantitativas, em vez de por uma técnica descritiva. O livro era voltado para profissionais de computação sérios, que desejavam um conhecimento detalhado sobre computadores. A maioria dos leitores deste livro não planeja se tornar arquiteto de computador. Contudo, o desempenho e o baixo consumo dos futuros sistemas de software será drasticamente afetado pela forma como os projetistas de software entendem as técnicas de hardware básicas, em funcionamento, dentro de um sistema. Assim, aqueles que escrevem compiladores, projetistas de sistemas operacionais, programadores de banco de dados e a maioria dos outros engenheiros de software precisam de um fundamento sólido sobre os princípios apresentados neste livro. De modo semelhante, os projetistas de hardware precisam entender claramente os efeitos de seu trabalho sobre as aplicações de software. Assim, sabíamos que este livro tinha de ser muito mais do que um subconjunto do material contido em Arquitetura de Computadores, e o material foi bastante revisado para atender esse público-alvo diferente. Ficamos tão satisfeitos com o resultado que as edições seguintes de Arquitetura de Computadores foram revisadas para remover a maior parte do material introdutório; logo, há muito menos repetição entre eles hoje, do que nas primeiras edições dos dois livros.
Mudanças para a 5ª edição Tivemos cinco objetivos principais para esta 5ª edição de Organização e Projeto de Computadores: demonstrar a importância de conhecer o hardware por meio de um exemplo comum; destacar os principais temas no decorrer dos tópicos, usando ícones na margem; atualizar os exemplos para refletir a passagem da era do PC para a era pós-PC; espalhar o material sobre E/S por todo o livro, em vez de isolá-lo em um único capítulo; e atualizar o conteúdo técnico para refletir as mudanças ocorridas na área desde a publicação da 4ª edição em 2009. Antes de discutirmos os objetivos com detalhes, vejamos a tabela a seguir. Ela mostra as sequências de hardware e software no decorrer do livro. Os Capítulos 1, 4, 5 e 6 são encontrados nas duas sequências, não importando a experiência ou o foco. O Capítulo 1 inclui uma discussão sobre a importância da potência e como ela motiva a mudança de microprocessadores de core (núcleo) único para multicore, e apresenta as oito grandes ideias na arquitetura de computadores. O Capítulo 2 provavelmente servirá de material de revisão para os focados em hardware, mas é uma leitura essencial para aqueles voltados para o software, especialmente para os leitores interessados em aprender mais sobre os compiladores e as linguagens de programação orientadas a objeto. O Capítulo 3 está voltado para os leitores interessados em construir um caminho de dados ou aprender mais sobre aritmética de ponto flutuante. Alguns pularão partes do Capítulo 3, ou porque não precisam delas ou porque são uma revisão. No entanto, apresentamos o exemplo continuado de multiplicação matricial neste capítulo, mostrando como o paralelismo de subword oferece uma melhoria quádrupla, portanto, não deixe de ler as Seções 3.6 a 3.8. O Capítulo 4 explica os processadores em pipeline. As Seções 4.1, 4.5 e 4.10 oferecem resumos e a Seção 4.12 oferece o próximo aumento de desempenho para a multiplicação matricial, para os voltados ao software. Porém, aqueles mais interessados em hardware, descobrirão que esse capítulo apresenta um material básico; eles também podem, dependendo de sua base, querer ler primeiro o Apêndice B, sobre projeto lógico. O último capítulo sobre multicores, multiprocessadores e clusters é um material basicamente novo e deve ser lido por todos. Ele foi significativamente reorganizado nesta edição, tornando o fluxo de ideias mais natural e incluindo muito mais profundidade sobre GPUs, computadores em escala de warehouse e interface hardware-software das placas de interface de rede, a base para os clusters.
Capítulo ou Apêndice 1. Abstrações e Tecnologias Computacionais
Seções 1.1 a 1.11
2. Instruções: A Linguagem dos Computadores 2.1 a 2.14 2.15 a 2.19 3. Aritmética Computacional
3.1 a 3.5 3.6 a 3.8 (Paralelismo subword) 3.9 a 3.10 (Falácias)
B. Fundamentos de Projeto Lógico
B.1 a B.13
4. O Processador
4.1 (Visão geral) 4.2 (Convenções lógicas) 4.3 a 4.4 (Implementação simples) 4.5 (Visão geral do pipelining) 4.6 (Caminho de dados em pipeline) 4.7 a 4.9 (Hazards, exceções) 4.10 a 4.12 (Paralelo, vida real) 4.13 a 4.14 (Falácias)
5. Grande e Rápida: Explorando a Hierarquia de Memória
5.1 a 5.10 5.11 a 5.14
6. Processadores paralelos do cliente à nuvem
6.1 a 6.8 6.9 a 6.13
A. Montadores, Link-editores e o Simulador SPIM
A.1 a A.11
Leia cuidadosamente
Foco do software
Foco do hardware
Leia se tiver tempo Revise ou leia
O primeiro dos cinco objetivos desta quinta edição foi demonstrar a importância de compreender o hardware moderno para obter bom desempenho e baixo consumo de energia com um exemplo concreto. Como já dissemos, começamos com o paralelismo subword no Capítulo 3, para melhorar a multiplicação matricial por um fator de 4. Dobramos o desempenho no Capítulo 4, desdobrando o laço para demonstrar o valor do paralelismo em nível de instrução. O Capítulo 5 dobra o desempenho novamente, otimizando para caches com uso de bloqueio. Por fim, o Capítulo 6 demonstra um ganho de velocidade de 14 dos 16 processadores usando o paralelismo em nível de thread. Todas as quatro otimizações no total acrescentam apenas 24 linhas de código C ao nosso exemplo inicial de multiplicação matricial. O segundo objetivo foi ajudar os leitores a separar a floresta das árvores, identificando desde cedo as oito grandes ideias da arquitetura do computador e depois indicando todos os lugares em que elas ocorrem pelo restante do livro. Usamos (espera-se) ícones fáceis de lembrar na margem, e destacamos a palavra correspondente no texto, para que os leitores se lembrem desses oito temas. Existem quase 100 citações no livro. Nenhum capítulo possui menos de sete exemplos de grandes ideias, e nenhuma ideia é citada menos de cinco vezes. O desempenho por meio de paralelismo, pipelining e predição são as três mais populares das grandes ideias, seguidas de perto pela Lei de Moore. O capítulo sobre o processador (4) é aquele com mais exemplos, o que não é uma surpresa, pois provavelmente recebeu a maior atenção dos arquitetos de computador. A grande ideia que aparece em todos os capítulos é o desempenho através do paralelismo, que é uma observação aprazível, dada a ênfase recente no paralelismo na área e nas edições deste livro. O terceiro objetivo foi reconhecer, nesta edição, a mudança de geração na computação desde a era do PC até a era pós-PC, com nossos exemplos e material. Assim, o Capítulo 1 aprofunda-se nas entranhas de um tablet e não em um PC, e o Capítulo 6 descreve a infraestrutura de computação da nuvem. Também incluímos o ARM, que é o conjunto de instruções escolhido nos dispositivos pessoais móveis da era pós-PC, bem como o conjunto de instruções x86, que dominou a era PC e (até aqui) domina a computação em nuvem. O quarto objetivo foi espalhar o material sobre E/S por todo o livro, em vez de
incluí-lo em seu próprio capítulo, assim como espalhamos o paralelismo por todos os capítulos na 4ª edição. Logo, o material sobre E/S nesta edição pode ser encontrado nas Seções 1.4, 4.9, 5.2 e 5.5. A ideia é que os leitores (e instrutores) provavelmente abordarão a E/S se ela não estiver segregada em seu próprio capítulo. Esta é uma área em rápida mudança e, como sempre acontece a cada nova edição, um objetivo importante é atualizar o conteúdo técnico. O exemplo corrente é o ARM Córtex A8 e o Intel Core i7, refletindo nossa era pós-PC. Outros destaques incluem uma visão geral do novo conjunto de instruções de 64 bits do ARMv8, um tutorial sobre GPUs, que explica sua terminologia exclusiva, uma abordagem mais profunda sobre os computadores em escala de warehouse, que compõem a nuvem, e um mergulho nas placas Ethernet de 10 Gigabytes. Por fim, atualizamos todos os exercícios. Embora alguns elementos tenham mudado, preservamos os elementos úteis das edições anteriores. Para fazer com que o livro funcione melhor como referência, colocamos as definições dos novos termos nas margens, em sua primeira ocorrência. A seção “Entendendo o Desempenho do Programa”, presente em cada capítulo, ajuda os leitores a entenderem o desempenho de seus programas e como melhorá-lo, assim como o elemento “Interface de Hardware/Software” ajudou os leitores a entenderem as escolhas nessa interface. A seção “Colocando em Perspectiva” continua, de modo que o leitor verá a floresta, apesar de todas as árvores. As seções “Verifique Você Mesmo” ajudam os leitores a confirmarem sua compreensão do material na primeira leitura com as respostas fornecidas ao final de cada capítulo. Esta edição ainda inclui o cartão de referência MIPS, inspirado pelo “Green Card” do IBM System/360. Esse cartão foi atualizado e deverá ser uma referência prática na escrita de programas em linguagem assembly MIPS.
Comentários finais Ao ler a seção de agradecimentos a seguir, você verá que trabalhamos bastante para corrigir erros. Como um livro passa por muitas tiragens, temos a oportunidade de fazer ainda mais correções. Se você descobrir quaisquer outros erros, por favor, entre em contato com a editora por correio eletrônico em [email protected], ou pelo correio tradicional, usando o endereço encontrado na página de copyright. Esta edição marca uma quebra na duradoura colaboração entre Hennessy e Patterson, que começou em 1989. As demandas da condução de uma das maiores universidades do mundo significam que o Presidente Hennessy não poderia mais se comprometer a escrever uma nova edição. O outro autor se sentiu como um malabarista caminhando sem uma rede de segurança. Assim, as pessoas nos agradecimentos e os colegas da Berkeley desempenharam um papel ainda maior na formação do conteúdo deste livro. Apesar disso, desta vez só existe um único autor a culpar pelo novo material que você está para ler.
Agradecimentos da 5ª edição A cada edição deste livro, tivemos a sorte de receber ajuda de muitos leitores, revisores e colaboradores. Cada uma dessas pessoas ajudou a tornar este livro melhor. O Capítulo 6 foi tão intensamente revisado que fizemos uma revisão separada para ideias e conteúdo, e fiz mudanças com base no retorno recebido de cada revisor. Gostaria de agradecer a Christos Kozyrakis, da Stanford University, por sugerir o uso da interface de rede para clusters, a fim de demonstrar a interface hardware-software da E/S e pelas sugestões sobre a organização do restante do capítulo; a Mario Flagsilk, da Stanford University, por fornecer detalhes, diagramas e medições de desempenho da NIC NetFPGA; e pelas sugestões sobre como melhorar o capítulo, a David Kaeli, da Northeastern University, Partha Ranganathan, da HP Labs, David Wood, da University of Wisconsin, e meus colegas da Berkeley Siamak Faridani, Shoaib Kamil, Yunsup Lee, Zhangxi Tan e Andrew Waterman. Gostaria de agradecer especialmente a Rimas Avizenis, da UC Berkeley, que desenvolveu as diversas versões da multiplicação matricial e também forneceu os números de desempenho. Como trabalhei com seu pai enquanto era graduando na UCLA, foi uma bela simetria trabalhar com Rimas na UCB. Também gostaria de agradecer ao meu colaborador de vários anos Randy Katz, da UC Berkeley, que ajudou a desenvolver o conceito das grandes ideias em arquitetura de computadores como parte da extensa revisão que realizamos juntos em uma turma de graduação. Gostaria de agradecer a David Kirk, John Nickolls e seus colegas na NVIDIA (Michael Garland, John Montrym, Doug Voorhies, Lars Nyland, Erik Lindholm, Paulius Micikevicius, Massimiliano Fatica, Stuart Oberman e Vasily Volkov) por escreverem o primeiro apêndice detalhado sobre GPUs. Gostaria de expressar novamente meu apreço a Jim Larus, recentemente nomeado diretor da School of Computer and Communications Science na EPFL, por sua disposição em contribuir com sua experiência em programação na linguagem assembly, além de aceitar que os leitores deste livro usem o simulador que ele desenvolveu e mantém. Também sou grato a Jason Bakos, da University of South Carolina, que atualizou e criou novos exercícios para esta edição, trabalhando com os originais preparados para a 4ª edição por Perry Alexander (The University of Kansas);
Javier Bruguera (Universidade de Santiago de Compostela); Matthew Farrens (University of California, Davis); David Kaeli (Northeastern University); Nicole Kaiyan (University of Adelaide); John Oliver (Cal Poly, San Luis Obispo); Milos Prvulovic (Georgia Tech); e Jichuan Chang, Jacob Leverich, Kevin Lim e Partha Ranganathan (todos da Hewlett-Packard). Um agradecimento especial a Jason Bakos, por desenvolver os novos slides para palestras. Sou grato aos muitos instrutores que responderam às pesquisas da editora, revisaram nossas propostas e participaram de grupos de foco para analisar e responder aos nossos planos para esta edição. Entre eles estão: Grupo de foco em 2012: Bruce Barton (Suffolk County Community College), Jeff Braun (Montana Tech), Ed Gehringer (North Carolina State), Michael Goldweber (Xavier University), Ed Harcourt (St. Lawrence University), Mark Hill (University of Wisconsin, Madison), Patrick Homer (University of Arizona), Norm Jouppi (HP Labs), Dave Kaeli (Northeastern University), Christos Kozyrakis (Stanford University), Zachary Kurmas (Grand Valley State University), Jae C. Oh (Syracuse University), Lu Peng (LSU), Milos Prvulovic (Georgia Tech), Partha Ranganathan (HP Labs), David Wood (University of Wisconsin), Craig Zilles (University of Illinois at Urbana-Champaign). Inspeções e críticas: Mahmoud Abou-Nasr (Wayne State University), Perry Alexander (The University of Kansas), Hakan Aydin (George Mason University), Hussein Badr (State University of New York at Stony Brook), Mac Baker (Virginia Military Institute), Ron Barnes (George Mason University), Douglas Blough (Georgia Institute of Technology), Kevin Bolding (Seattle Pacific University), Miodrag Bolic (University of Ottawa), John Bonomo (Westminster College), Jeff Braun (Montana Tech), Tom Briggs (Shippensburg University), Scott Burgess (Humboldt State University), Fazli Can (Bilkent University), Warren R. Carithers (Rochester Institute of Technology), Bruce Carlton (Mesa Community College), Nicholas Carter (University of Illinois at Urbana-Champaign), Anthony Cocchi (The City University of New York), Don Cooley (Utah State University), Robert D. Cupper (Allegheny College), Edward W. Davis (North Carolina State University), Nathaniel J. Davis (Air Force Institute of Technology), Molisa Derk (Oklahoma City University), Derek Eager (University of Saskatchewan), Ernest Ferguson (Northwest Missouri State University), Rhonda Kay Gaede (The University of Alabama), Etienne M. Gagnon (UQAM), Costa Gerousis (Christopher Newport University), Paul Gillard (Memorial University of Newfoundland), Michael Goldweber (Xavier University), Georgia
Grant (College of San Mateo), Merrill Hall (The Master’s College), Tyson Hall (Southern Adventist University), Ed Harcourt (St. Lawrence University), Justin E. Harlow (University of South Florida), Paul F. Hemler (Hampden-Sydney College), Martin Herbordt (Boston University), Steve J. Hodges (Cabrillo College), Kenneth Hopkinson (Cornell University), Dalton Hunkins (St. Bonaventure University), Baback Izadi (State University of New York — New Paltz), Reza Jafari, Robert W. Johnson (Colorado Technical University), Bharat Joshi (University of North Carolina, Charlotte), Nagarajan Kandasamy (Drexel University), Rajiv Kapadia, Ryan Kastner (University of California, Santa Barbara), E.J. Kim (Texas A&M University), Jihong Kim (Seoul National University), Jim Kirk (Union University), Geoffrey S. Knauth (Lycoming College), Manish M. Kochhal (Wayne State), Suzan Koknar-Tezel (Saint Joseph’s University), Angkul Kongmunvattana (Columbus State University), April Kontostathis (Ursinus College), Christos Kozyrakis (Stanford University), Danny Krizanc (Wesleyan University), Ashok Kumar, S. Kumar (The University of Texas), Zachary Kurmas (Grand Valley State University), Robert N. Lea (University of Houston), Baoxin Li (Arizona State University), Li Liao (University of Delaware), Gary Livingston (University of Massachusetts), Michael Lyle, Douglas W. Lynn (Oregon Institute of Technology), Yashwant K Malaiya (Colorado State University), Bill Mark (University of Texas at Austin), Ananda Mondal (Claflin University), Alvin Moser (Seattle University), Walid Najjar (University of California, Riverside), Danial J. Neebel (Loras College), John Nestor (Lafayette College), Jae C. Oh (Syracuse University), Joe Oldham (Centre College), Timour Paltashev, James Parkerson (University of Arkansas), Shaunak Pawagi (SUNY at Stony Brook), Steve Pearce, Ted Pedersen (University of Minnesota), Lu Peng (Louisiana State University), Gregory D Peterson (The University of Tennessee), Milos Prvulovic (Georgia Tech), Partha Ranganathan (HP Labs), Dejan Raskovic (University of Alaska, Fairbanks) Brad Richards (University of Puget Sound), Roman Rozanov, Louis Rubinfield (Villanova University), Md Abdus Salam (Southern University), Augustine Samba (Kent State University), Robert Schaefer (Daniel Webster College), Carolyn J. C. Schauble (Colorado State University), Keith Schubert (CSU San Bernardino), William L. Schultz, Kelly Shaw (University of Richmond), Shahram Shirani (McMaster University), Scott Sigman (Drury University), Bruce Smith, David Smith, Jeff W. Smith (University of Georgia, Athens), Mark Smotherman (Clemson University), Philip Snyder (Johns Hopkins University), Alex Sprintson (Texas A&M), Timothy D. Stanley (Brigham Young University),
Dean Stevens (Morningside College), Nozar Tabrizi (Kettering University), Yuval Tamir (UCLA), Alexander Taubin (Boston University), Will Thacker (Winthrop University), Mithuna Thottethodi (Purdue University), Manghui Tu (Southern Utah University), Dean Tullsen (UC San Diego), Rama Viswanathan (Beloit College), Ken Vollmar (Missouri State University), Guoping Wang (Indiana-Purdue University), Patricia Wenner (Bucknell University), Kent Wilken (University of California, Davis), David Wolfe (Gustavus Adolphus College), David Wood (University of Wisconsin, Madison), Ki Hwan Yum (University of Texas, San Antonio), Mohamed Zahran (City College of New York), Gerald D. Zarnett (Ryerson University), Nian Zhang (South Dakota School of Mines & Technology), Jiling Zhong (Troy University), Huiyang Zhou (The University of Central Florida) e Weiyu Zhu (Illinois Wesleyan University). Um agradecimento especial também a Mark Smotherman, que fez uma revisão final cuidadosa, e descobriu problemas técnicos e de escrita, o que melhorou significativamente a qualidade desta edição. Gostaríamos de agradecer a toda a família Morgan Kaufmann, que concordou em publicar este livro novamente, sob a liderança capaz de Todd Green e Nate McFadden: certamente, eu não conseguiria terminar este livro sem eles. Também queremos estender os agradecimentos a Lisa Jones, que gerenciou o processo de produção do livro, e a Russell Purdy, que executou o projeto da capa. A nova capa é uma conexão inteligente entre o conteúdo da era pós-PC desta edição e a capa da 1ª edição. As contribuições de quase 150 pessoas que mencionamos aqui tornaram esta edição nosso melhor livro até agora. Divirta-se! David A. Patterson
Abstrações e Tecnologias Computacionais A civilização avança ampliando o número de operações importantes que podem ser realizadas sem se pensar nelas. Alfred North Whitehead Uma Introdução à Matemática, 1911
1.1 Introdução 1.2 Oito grandes ideias sobre arquitetura de computadores 1.3 Por trás do programa 1.4 Sob as tampas 1.5 Tecnologias para a montagem de processadores e memória 1.6 Desempenho 1.7 A barreira da potência 1.8 Mudança de mares: Passando de processadores para multiprocessadores 1.9 Vida real: Fabricação e benchmarking do Intel Core i7 1.10 Falácias e armadilhas 1.11 Comentários finais 1.12 Exercícios
1.1. Introdução Bem-vindo a este livro! Estamos felizes por ter a oportunidade de compartilhar o entusiasmo do mundo dos sistemas computacionais. Esse não é um campo árido e monótono, no qual o progresso é glacial e as novas ideias se atrofiam pelo esquecimento. Não! Os computadores são o produto da impressionante e vibrante indústria da tecnologia da informação, cujos aspectos são responsáveis por quase 10% do produto interno bruto dos Estados Unidos, e cuja economia em parte tornou-se dependente dos rápidos avanços na tecnologia da informação, prometidos pela Lei de Moore. Essa área incomum abraça a inovação com uma velocidade surpreendente. Nos últimos 30 anos, surgiram inúmeros novos computadores que prometiam revolucionar a indústria da computação; essas revoluções foram interrompidas porque alguém sempre construía um computador ainda melhor. Essa corrida para inovar levou a um progresso sem precedentes desde o início da computação eletrônica no final da década de 1940. Se o setor de transportes, por exemplo, tivesse tido o mesmo desenvolvimento da indústria da computação, hoje nós poderíamos viajar de Nova York até Londres em aproximadamente um segundo por apenas alguns centavos. Imagine, por alguns instantes, como esse progresso mudaria a sociedade – morar no Taiti e trabalhar em São Francisco, indo para Moscou no início da noite a fim de assistir a uma apresentação do balé de Bolshoi. Não é difícil imaginar as implicações dessa mudança. Os computadores levaram a humanidade a enfrentar uma terceira revolução, a revolução da informação, que assumiu seu lugar junto das revoluções industrial e agrícola. A multiplicação da força e do alcance intelectual do ser humano naturalmente afetou muito nossas vidas cotidianas, além de ter mudado a maneira como conduzimos a busca de novos conhecimentos. Agora, existe uma nova veia de investigação científica, com a ciência da computação unindo os cientistas teóricos e experimentais na exploração de novas fronteiras na astronomia, biologia, química, física etc. A revolução dos computadores continua. Cada vez que o custo da computação melhora por um fator de 10, as oportunidades para os computadores se multiplicam. As aplicações que eram economicamente proibitivas, de repente se tornam viáveis. As seguintes aplicações, em um passado recente, eram “ficção científica para a computação”: ▪ Computação em automóveis: até os microprocessadores melhorarem
significativamente de preço e desempenho no início dos anos 80, o controle dos carros por computadores era considerado um absurdo. Hoje, os computadores reduzem a poluição e melhoram a eficiência do combustível, usando controles no motor, além de aumentarem a segurança por meio da prevenção de derrapagens perigosas e pela ativação de air-bags para proteger os passageiros em caso de colisão. ▪ Telefones celulares: quem sonharia que os avanços dos sistemas computacionais levariam aos telefones portáteis, permitindo a comunicação entre pessoas em quase todo lugar do mundo? ▪ Projeto do genoma humano: o custo do equipamento computacional para mapear e analisar as sequências do DNA humano é de centenas de milhares de dólares. É improvável que alguém teria considerado esse projeto se os custos computacionais fossem 10 a 100 vezes mais altos, como há 15 ou 25 anos. Além do mais, os custos continuam a cair; você poderá adquirir seu próprio genoma, permitindo que a assistência médica seja ajustada a você mesmo. ▪ World Wide Web: ainda não existente na época da primeira edição deste livro, a World Wide Web transformou nossa sociedade. Para muitos, a Web substituiu as bibliotecas e os jornais. ▪ Motores de busca: à medida que o conteúdo da Web crescia em tamanho e em valor, encontrar informações relevantes tornou-se cada vez mais importante. Hoje, muitas pessoas contam com ferramentas de busca para tantas coisas em suas vidas que seria muito difícil viver sem elas. Claramente, os avanços dessa tecnologia hoje afetam quase todos os aspectos da nossa sociedade. Os avanços de hardware permitiram que os programadores criassem softwares maravilhosamente úteis e explicassem por que os computadores são onipresentes. A ficção científica de hoje sugere as aplicações que fazem sucesso amanhã: já a caminho estão os mundos virtuais, a sociedade sem dinheiro em espécie e carros que podem dirigir sem auxílio humano.
Classes de aplicações de computador e suas características Embora um conjunto comum de tecnologias de hardware (discutidas nas Seções 1.4 e 1.5) seja usado em computadores, variando dos dispositivos domésticos inteligentes e telefones celulares aos maiores supercomputadores, essas diferentes aplicações possuem diferentes necessidades de projeto e empregam os
fundamentos das tecnologias de hardware de diversas maneiras. Genericamente falando, os computadores são usados em três diferentes classes de aplicações. Os computadores desktop (PCs) são possivelmente os modelos mais conhecidos de computação e caracterizam-se pelo computador pessoal, que a maioria dos leitores deste livro provavelmente já usou extensivamente. Os computadores desktop enfatizam o bom desempenho a um único usuário por um baixo custo e normalmente são usados para executar software independente. A evolução de muitas tecnologias de computação é motivada por essa classe da computação, que só tem cerca de 35 anos!
computadores desktop Um computador projetado para uso por uma única pessoa, normalmente incorporando um monitor gráfico, um teclado e um mouse. Os servidores são a forma moderna do que, antes, eram computadores muito maiores, e, em geral, são acessados apenas por meio de uma rede. Os servidores são projetados para suportar grandes cargas de trabalho, que podem consistir em uma única aplicação complexa, normalmente científica ou de engenharia, ou manipular muitas tarefas pequenas, como ocorreria no caso de um grande servidor Web. Essas aplicações muitas vezes são baseadas em software de outra origem (como um banco de dados ou sistema de simulação), mas, frequentemente, são modificadas ou personalizadas para uma função específica. Os servidores são construídos a partir da mesma tecnologia básica dos computadores desktop, mas fornecem uma maior capacidade de expansão, tanto da capacidade de processamento quanto de entrada/saída. Em geral, os servidores também dão grande ênfase à estabilidade, já que uma falha normalmente é mais prejudicial do que seria em um computador desktop de um único usuário.
servidor Um computador usado para executar grandes programas para múltiplos usuários, quase sempre de maneira simultânea e normalmente acessado apenas por meio de uma rede. Os servidores abrangem a faixa mais ampla em termos de custo e capacidade.
Na sua forma mais simples, um servidor pode ser pouco mais do que uma máquina desktop sem monitor ou teclado e com um custo de mil dólares. Esses servidores de baixa capacidade normalmente são usados para armazenamento de arquivos, pequenas aplicações comerciais ou serviço Web simples (Seção 6.10). No outro extremo, estão os supercomputadores, que, atualmente, consistem em dezenas de milhares de processadores e, em geral, de terabytes de memória, e custam desde dezenas até centenas de milhões de dólares. Os supercomputadores normalmente são usados para cálculos científicos e de engenharia de alta capacidade, como previsão do tempo, exploração de petróleo, determinação da estrutura da proteína e outros problemas de grande porte. Embora esses supercomputadores representem o máximo da capacidade de computação, eles são uma fração relativamente pequena dos servidores e do mercado de computadores em termos de receita total.
supercomputador Uma classe de computadores com desempenho e custo mais altos; eles são configurados como servidores e normalmente custam de dezenas a centenas de milhões de dólares.
terabyte (TB) Originalmente, 1.099.511.627.776 (240) bytes, embora alguns desenvolvedores de sistemas de comunicações e de armazenamento secundário o tenham redefinido como significando 1.000.000.000.000 (1012) bytes. Para diminuir a confusão, agora usamos o termo tebibyte (TiB) para 240 bytes, definindo terabyte (TB) para indicar 1012 bytes. A Figura 1.1 mostra a faixa completa de valores decimais e binários, com seus nomes.
FIGURA 1.1 A ambiguidade 2x versus 10y bytes foi resolvida acrescentando-se uma notação binária para todos os termos de tamanho comuns. Na última coluna, observamos como o termo binário é maior do que seu correspondente decimal, o que é visto quando descemos na tabela. Esses prefixos funcionam para bits e também como bytes, portanto gigabit (Gb) é 109 bits, enquanto gibibits (Gib) é 230 bits.
Os computadores embutidos são a maior classe de computadores e abrangem a faixa mais ampla de aplicações e desempenho. Os computadores embutidos incluem os microprocessadores encontrados em seu carro, os computadores em um aparelho de televisão digital, e as redes de processadores que controlam um avião moderno ou um navio de carga. Os sistemas de computação embutidos são projetados para executar uma aplicação ou um conjunto de aplicações relacionadas como um único sistema; portanto, apesar do grande número de computadores embutidos, a maioria dos usuários nunca vê realmente que está usando um computador!
computador embutido Um computador dentro de outro dispositivo, usado para executar uma aplicação predeterminada ou um conjunto de softwares. As aplicações embutidas normalmente possuem necessidades específicas que combinam um desempenho mínimo com limitações rígidas em relação a custo ou potência. Por exemplo, considere um telefone celular: o processador só precisa ser tão rápido quanto o necessário para manipular sua função limitada;
além disso, minimizar custo e potência é o objetivo mais importante. Apesar do seu baixo custo, os computadores embutidos frequentemente possuem menor tolerância a falhas, já que os resultados podem variar desde um simples incômodo, quando sua nova televisão falha, até o completo desastre que poderia ocorrer quando o computador em um avião ou em um navio falha. Nas aplicações embutidas orientadas ao consumidor, como um eletrodoméstico digital, a estabilidade é obtida principalmente por meio da simplicidade – a ênfase está em realizar uma função o mais perfeitamente possível. Nos grandes sistemas embutidos, em geral, são empregadas as mesmas técnicas de redundância utilizadas no mundo dos servidores. Embora este livro se concentre nos computadores de uso geral, a maioria dos conceitos se aplica diretamente – ou com ligeiras modificações – aos computadores embutidos.
Detalhamento os detalhamentos são seções curtas usadas em todo o texto para fornecer mais detalhes sobre um determinado assunto, que pode ser de interesse. Os leitores que não possuem um interesse específico no tema podem pular essas seções, já que o material subsequente nunca dependerá do conteúdo desta seção. Muitos processadores embutidos são projetados usando núcleos de processador, uma versão de um processador escrita em uma linguagem de descrição de hardware como Verilog ou VHDL (Capítulo 4). O núcleo permite que um projetista integre outro hardware específico de uma aplicação com o núcleo do processador para a fabricação em um único chip.
Bem-vindo à era pós-PC O andar contínuo da tecnologia ocasiona mudanças de geração no hardware de computador que agitam todo o setor de tecnologia da informação. Desde a última edição do livro, passamos por essa mudança, tão significativa no passado quanto a mudança que começou há 30 anos com os computadores pessoais. O PC está sendo substituído pelo dispositivo móvel pessoal (PMD — Personal Mobile Device). PMDs operam com bateria, com conectividade sem fios com a Internet, e normalmente custam centenas de dólares; como os PCs, os usuários podem baixar software (“apps”) para serem executados neles. Ao contrário dos PCs, eles não possuem mais um teclado e mouse, e provavelmente utilizam uma tela sensível ao toque ou até mesmo entrada de voz. O PMD de hoje é um
smartphone ou um computador tablet, mas amanhã poderá incluir óculos eletrônicos. A Figura 1.2 mostra o rápido crescimento dos tablets e smartphones em comparação ao dos PCs e telefones celulares tradicionais.
FIGURA 1.2 O número de tablets e smartphones fabricados por ano, refletindo a era pós-PC, contra os computadores pessoais e telefones celulares tradicionais. Smartphones representam o crescimento recente no setor de telefone celular, e ultrapassaram os PCs em 2011. Os tablets são a categoria com crescimento mais rápido, quase dobrando entre 2011 e 2012. Recentemente, as categorias de PCs e celulares tradicionais estão relativamente planas ou em declínio.
Personal Mobile Devices (PMDs) são pequenos dispositivos sem fio para realizar a conexão com a Internet; eles utilizam baterias para gerar energia e o software é instalado baixando aplicativos. Alguns exemplos comuns são smartphones e tablets. Apoderando-se do servidor tradicional está a computação em nuvem, que conta com gigantes centros de dados, conhecidos como Computadores em
Escala de Warehouse (WSCs — Warehouse Scale Computadores). Empresas como Amazon e Google montam esses WSCs contendo 100.000 servidores e depois permitem que as empresas aluguem partes deles para que possam oferecer serviços de software para DMPs, sem a necessidade de montar WSCs próprios. Em vez disso, o Software as a Service (SaaS) implementado por meio da nuvem está revolucionando o setor de software, assim como DMPs e WSCs estão revolucionando o setor de hardware. Os desenvolvedores de software atuais normalmente possuem uma parte de sua aplicação rodando no PMD e uma parte rodando na nuvem.
Computação em nuvem se refere a um grande conjunto de servidores que prestam serviço através da Internet. Alguns provedores fornecem um número dinâmico variante de servidores como um serviço.
Software as a Service (SaaS) oferece software e dados como um serviço pela Internet, normalmente através de um programa magro, como um navegador, que roda em dispositivos clientes locais, em vez de um código binário que precisa ser instalado e executado totalmente nesse dispositivo. Alguns exemplos são a busca na Web e as redes sociais.
O que você pode aprender neste livro Os bons programadores sempre se preocuparam com o desempenho de seus programas porque gerar resultados rapidamente para o usuário é uma condição essencial na criação bem-sucedida de software. Nas décadas de 1960 e 1970, uma grande limitação no desempenho dos computadores era o tamanho da memória do computador. Assim, os programadores em geral seguiam um princípio simples: minimizar o espaço ocupado na memória para tornar os programas mais rápidos. Na última década, os avanços em arquitetura de computadores e nas tecnologias de fabricação de memórias reduziram drasticamente a importância do tamanho da memória na maioria das aplicações, com exceção dos sistemas embutidos. Agora, os programadores interessados em desempenho precisam entender os problemas que substituíram o modelo de memória simples dos anos 1960: a
natureza paralela dos processadores e a natureza hierárquica das memórias. Além do mais, conforme explicamos na Seção 1.7, os programadores de hoje precisam se preocupar com a eficiência em termos de consumo de energia dos seus programas rodando no PMD ou na nuvem, o que também requer conhecer o que está por trás do seu código. Os programadores que desejam construir versões competitivas do software precisarão, portanto, aumentar seu conhecimento em organização de computadores. Sentimo-nos honrados com a oportunidade de explicar o que existe dentro da máquina revolucionária, decifrando o software por trás do seu programa e o hardware sob a tampa do seu computador. Ao concluir este livro, acreditamos que você será capaz de responder às seguintes perguntas: ▪ Como os programas escritos em uma linguagem de alto nível, como C ou Java, são traduzidos para a linguagem de máquina e como o hardware executa os programas resultantes? Compreender esses conceitos forma o alicerce para entender os aspectos do hardware e software que afetam o desempenho dos programas. ▪ O que é a interface entre o software e o hardware, e como o software instrui o hardware a realizar as funções necessárias? Esses conceitos são vitais para entender como escrever muitos tipos de software. ▪ O que determina o desempenho de um programa e como um programador pode melhorar o desempenho? Como veremos, isso depende do programa original, da tradução desse programa para a linguagem do computador e da eficiência do hardware em executar o programa. ▪ Que técnicas podem ser usadas pelos projetistas de hardware para melhorar o desempenho? Este livro apresentará os conceitos básicos do projeto de computador moderno. O leitor interessado encontrará muito mais material sobre esse assunto em nosso livro avançado, Arquitetura de Computadores: Uma abordagem quantitativa. ▪ Que técnicas podem ser usadas pelos projetistas de hardware para aumentar a economia de energia? O que o programador pode fazer para ajudar ou impedir esse processo? ▪ Quais são os motivos e as consequências da mudança recente do processamento sequencial para o processamento paralelo? Este livro oferece a motivação, descreve os mecanismos de hardware atuais para dar suporte ao paralelismo e estuda a nova geração de microprocessadores “multicore” (Capítulo 6). ▪ Desde o primeiro computador comercial em 1951, que grandes ideias os
arquitetos de computador tiveram para estabelecer a base da computação moderna?
microprocessador multicore Um microprocessador contendo múltiplos processadores (“cores” ou núcleos) em um único circuito integrado. Sem entender as respostas a essas perguntas, melhorar o desempenho do seu programa em um computador moderno ou avaliar quais recursos podem tornar um computador melhor do que outro para uma determinada aplicação será um complicado processo de tentativa e erro, em vez de um procedimento científico conduzido por consciência e análise. Este primeiro capítulo é a base para o restante do livro. Ele apresenta as ideias e definições básicas, coloca os principais componentes de software e hardware em perspectiva, mostra como avaliar o desempenho e a potência, apresenta os circuitos integrados, a tecnologia que estimula a revolução dos computadores, e explica a mudança para núcleos múltiplos (multicores). Neste capítulo e em capítulos seguintes, você provavelmente verá muitas palavras novas ou palavras que já pode ter ouvido, mas não sabe ao certo o que significam. Não entre em pânico! Sim, há muita terminologia especial usada para descrever os computadores modernos, mas ela realmente ajuda, uma vez que nos permite descrever precisamente uma função ou capacidade. Além disso, os projetistas de computador (inclusive estes autores) adoram usar acrônimos, que são fáceis de entender quando se sabe o que as letras significam! Para ajudálo a lembrar e localizar termos, incluímos na margem uma definição destacada de cada termo novo na primeira vez que aparece no texto. Após um pequeno período trabalhando com a terminologia, você será fluente e seus amigos ficarão impressionados quando você usar corretamente palavras como BIOS, CPU, DIMM, DRAM, PCIe, SATA e muitas outras.
Acrônimo Uma palavra construída tomando-se as letras iniciais das palavras. Por exemplo: RAM é um acrônimo para Random Access Memory (memória de acesso aleatório) e CPU é um acrônimo para Central Processing Unit (Unidade Central de Processamento).
Para enfatizar como os sistemas de software e hardware usados para executar um programa irão afetar o desempenho, usamos uma seção especial, “Entendendo o desempenho dos programas”, em todo o livro, para resumir importantes conceitos quanto ao desempenho do programa. A primeira aparece a seguir.
Entendendo o desempenho dos programas O desempenho de um programa depende de uma combinação entre a eficácia dos algoritmos usados no programa, os sistemas de software usados para criar e traduzir o programa para instruções de máquina e da eficácia do computador em executar essas instruções, que podem incluir operações de entrada/saída (E/S). A tabela a seguir descreve como o hardware e o software afetam o desempenho. Componente de hardware ou software
Como este componente afeta o desempenho
Onde este assunto é abordado?
Algoritmo
Determina o número de instruções do código-fonte e o número de operações de E/S realizadas
Outros livros!
Linguagem de programação, compilador e arquitetura
Determina o número de instruções de máquina para cada instrução em nível de fonte
Capítulos 2 e 3
Processador e sistema de memória
Determina a velocidade em que as instruções podem ser executadas
Capítulos 4, 5 e 6
Sistema de E/S (hardware e sistema operacional)
Determina a velocidade em que as operações de E/S podem ser executadas
Capítulos 4, 5 e 6
Para demonstrar o impacto das ideias neste livro, melhoramos o desempenho de um programa em C que multiplica uma matriz por um vetor em uma sequência de capítulos. Cada etapa é baseada no conhecimento de como o hardware subjacente realmente funciona em um microprocessador moderno para melhorar o desempenho por um fator de 200! ▪ Na categoria de paralelismo em nível de dados, no Capítulo 3, usamos o paralelismo de subword por meio de C intrínseco para aumentar o desempenho por um fator de 3,8. ▪ Na categoria de paralelismo em nível de instrução, no Capítulo 4, usamos o desdobramento de loop para explorar a questão de instruções múltiplas e hardware de execução fora de ordem para melhorar o desempenho por outro fator de 2,3.
▪ Na categoria de otimização da hierarquia de memória, no Capítulo 5, usamos o bloqueio de cache para aumentar o desempenho em grandes matrizes por outro fator de 2,5. ▪ Na categoria de paralelismo em nível de thread, no Capítulo 6, usamos loops for paralelos no OpenMP para explorar o hardware multicore a fim de aumentar o desempenho por outro fator de 14.
Verifique você mesmo As Seções “Verifique você mesmo” destinam-se a ajudar os leitores a avaliar se compreenderam os principais conceitos apresentados em um capítulo e se entenderam as implicações desses conceitos. Algumas questões “Verifique você mesmo” possuem respostas simples; outras são para discussão em grupo. As respostas às questões específicas podem ser encontradas no final do capítulo. As questões “Verifique você mesmo” aparecem apenas no final de uma seção, fazendo com que fique mais fácil pulá-las se você estiver certo de que entendeu o assunto. 1. O número de processadores embutidos vendidos a cada ano supera, e muito, o número de processadores para PC e até mesmo pós-PC. Você pode confirmar ou negar isso com base em sua própria experiência? Tente contar o número de processadores embutidos na sua casa. Compare esse número com o número de computadores convencionais em sua casa. 2. Como mencionado anteriormente, tanto o software quanto o hardware afetam o desempenho de um programa. Você pode pensar em exemplo nos quais cada um dos fatores a seguir é o responsável pelo gargalo no desempenho? ▪ O algoritmo escolhido ▪ A linguagem de programação ou compilador ▪ O sistema operacional ▪ O processador ▪ O sistema de E/S e os dispositivos
1.2. Oito grandes ideias sobre arquitetura de computadores Agora, apresentamos oito grandes ideias que os arquitetos de computador inventaram nos últimos 60 anos de projetos de computadores. Essas ideias são tão poderosas que duraram muito tempo depois do primeiro computador que as usaram, com os arquitetos mais novos demonstrando sua admiração ao imitar seus predecessores. Essas grandes ideias são temas que estarão entrelaçados durante este e os próximos capítulos, quando surgirem os exemplos. Para indicar sua influência, nesta seção apresentamos ícones e termos destacados, que representam as grandes ideias, usando-os para identificar as quase 100 seções do livro que apresentam o uso das grandes ideias.
Projete pensando na Lei de Moore A única constante para os projetistas de computador é a mudança rápida, que é controlada em grande parte pela Lei de Moore. Ela declara que os recursos do circuito integrado dobram a cada 18 a 24 meses. A Lei de Moore resultou de uma previsão desse crescimento na capacidade do CI em 1965, por Gordon Moore, um dos fundadores da Intel. Como os projetos de computador podem durar anos, os recursos disponíveis por chip podem facilmente dobrar ou quadruplicar entre o início e o final do projeto. Assim como um atirador, os arquitetos de computador precisam antecipar onde estará a tecnologia quando o projeto terminar, e não quando ele começar. Usamos um gráfico da Lei de Moore “para cima e para a direita”, representando o projeto para a mudança rápida.
Use a abstração para simplificar o projeto Os arquitetos e os programadores de computador tiveram que inventar técnicas para que se tornassem mais produtivos, pois, de outra forma, o tempo de projeto aumentaria de modo insustentável quando os recursos aumentassem pela Lei de Moore. Uma técnica de produtividade importante para o hardware e o software é usar abstrações para representar o projeto em diferentes níveis de representação; os detalhes de nível mais baixo serão ocultados, para oferecer um modelo mais simples nos níveis mais altos. Usaremos o ícone de pintura abstrata para representar essa segunda grande ideia.
Torne o caso comum veloz Tornar o caso comum veloz costuma melhorar mais o desempenho do que otimizar o caso raro. Ironicamente, o caso comum normalmente é mais simples do que o caso raro e, portanto, geralmente é mais fácil de melhorar. Esse conselho do senso comum implica que você saberá qual é o caso comum, o que é possível apenas com experimentação e medição cuidadosas (Seção 1.6). Usamos um carro esportivo como ícone para tornar o caso comum veloz, pois a viagem mais comum tem um ou dois passageiros, e certamente é mais fácil tornar um carro esportivo veloz do que um utilitário!
Desempenho pelo paralelismo Desde o nascimento da computação, os arquitetos de computador têm oferecido projetos que geram mais desempenho realizando operações em paralelo. Veremos muitos exemplos de paralelismo neste livro. Usamos um avião a jato com vários motores como nosso ícone para desempenho paralelo.
Desempenho pelo pipelining
Um padrão de paralelismo em particular é tão prevalecente na arquitetura de computação que merece seu próprio nome: pipelining. Por exemplo, antes dos dispositivos contra incêndio, uma “brigada de baldes” respondia a um incêndio, a qual muitos filmes de cowboy mostram em resposta a um ato covarde de um vilão. Os moradores formam uma corrente humana para carregar uma fonte de água a um incêndio, pois eles poderiam mover os baldes muito mais rapidamente pela corrente, em vez de indivíduos correndo de um lado para outro. Nosso ícone de tubulação é uma sequência de tubos, com cada seção representando um estágio da tubulação.
Desempenho pela predição Seguindo o ditado de que pode ser melhor pedir perdão do que pedir permissão, a última grande ideia é a predição. Em alguns casos, na média, pode ser mais rápido prever e começar a trabalhar do que esperar até que você saiba ao certo, supondo que o mecanismo para se recuperar de um erro de previsão não seja não dispendioso e sua predição seja relativamente precisa. Usamos a bola de cristal de uma cartomante como nosso ícone de predição.
Hierarquia de memórias Os programadores desejam que a memória seja rápida, grande e barata, pois a velocidade da memória geralmente modela o desempenho, a capacidade limita o tamanho dos problemas que podem ser resolvidos e o custo da memória, hoje, geralmente é o maior custo do computador. Os arquitetos descobriram que podem resolver essas demandas em conflito com uma hierarquia de memórias, com a memória mais rápida, menor e mais cara por bit no topo da hierarquia e a mais lenta, maior e mais barata por bit no fundo. Conforme veremos no Capítulo 5, as caches dão ao programador a ilusão de que a memória principal é quase tão rápida quanto o topo da hierarquia e quase tão grande e barata quanto o fundo da hierarquia. Usamos um triângulo em camadas para representar a hierarquia da memória. A forma indica velocidade, custo e tamanho: quanto mais perto do topo, mais rápida e mais cara por bit é a memória; quanto mais larga a base da camada, maior é a memória.
Estabilidade pela redundância Os computadores não apenas precisam ser rápidos; eles precisam ser estáveis. Como qualquer dispositivo pode falhar, montamos sistemas estáveis incluindo componentes redundantes, que podem assumir o controle quando uma falha ocorre e ajudar a detectá-la. Usamos o caminhão de reboque como nosso ícone, pois os duplos pneus em cada lado de seus eixos traseiros permitem que o caminhão continue seguindo, mesmo quando um pneu fura. (Supõe-se que o motorista do caminhão seguirá imediatamente para um borracheiro, para que o pneu seja consertado e a redundância restaurada!)
1.3. Por trás do programa Em Paris, eles simplesmente olhavam perdidos quando eu falava em francês; nunca consegui fazer aqueles idiotas entenderem sua própria língua. Mark Twain, The Innocents Abroad, 1869
Uma aplicação típica, como um processador de textos ou um grande sistema de banco de dados, pode consistir em milhões de linhas de código e se basear em bibliotecas de software sofisticadas que implementam funções complexas no apoio à aplicação. Como veremos, o hardware em um computador só pode executar instruções de baixo nível, extremamente simples. Ir de uma aplicação complexa até as instruções simples envolve várias camadas de software que interpretam ou traduzem operações de alto nível nas instruções simples do computador, um exemplo da grande ideia da abstração.
A Figura 1.3 mostra que essas camadas de software são organizadas principalmente de maneira hierárquica, na qual as aplicações são o anel mais externo e uma variedade de software de sistemas situa-se entre o hardware e as aplicações.
FIGURA 1.3 Uma visão simplificada do hardware e software como camadas hierárquicas, mostradas como círculos concêntricos, em que o hardware está no centro e as aplicações aparecem externamente. Nas aplicações complexas, muitas vezes existem diversas camadas de software de aplicação. Por exemplo, um sistema de
banco de dados pode “rodar” sobre o software de sistemas hospedando uma aplicação, que, por sua vez, roda sobre o banco de dados.
software de sistemas Software que fornece serviços normalmente úteis, incluindo sistemas operacionais, compiladores, carregadores, e montadores. Existem muitos tipos de software de sistemas, mas dois tipos são fundamentais em todos os sistemas computacionais modernos: um sistema operacional e um compilador. Um sistema operacional fornece a interface entre o programa do usuário e o hardware e disponibiliza diversos serviços e funções de supervisão. Entre as funções mais importantes estão: ▪ Manipular as operações básicas de entrada e saída ▪ Alocar armazenamento e memória ▪ Providenciar o compartilhamento protegido do computador entre as diversas aplicações que o utilizam simultaneamente
sistema operacional Programa de supervisão que gerencia os recursos de um computador, em favor dos programas executados nessa máquina. Exemplos de sistemas operacionais em uso hoje são Linux, iOS e Windows. Os compiladores realizam outra função fundamental: a tradução de um programa escrito em uma linguagem de alto nível, como C, C + +, Java ou Visual Basic, em instruções que o hardware possa executar. Em razão da sofisticação das linguagens de programação modernas e das instruções simples executadas pelo hardware, a tradução de um programa de linguagem de alto nível para instruções de hardware é complexa. Daremos um breve resumo do processo aqui, e depois entraremos em mais detalhes, no Capítulo 2 e no Apêndice A.
compilador Um programa que traduz as instruções de linguagem de alto nível para instruções de linguagem assembly.
De uma linguagem de alto nível para a linguagem do hardware Para poder realmente falar com uma máquina eletrônica, você precisa enviar sinais elétricos. Os sinais mais fáceis de serem entendidos pelas máquinas são ligado (on) e desligado (off); portanto, o alfabeto da máquina se resume a apenas duas letras. Assim como as 26 letras do alfabeto português não limitam o quanto pode ser escrito, as duas letras do alfabeto do computador não limitam o que os computadores podem fazer. Os dois símbolos para essas duas letras são os números 0 e 1, e normalmente pensamos na linguagem de máquina como números na base 2, ou números binários. Chamamos cada “letra” de um dígito binário ou bit. Os computadores são escravos dos nossos comandos, chamados de instruções. As instruções, que são apenas grupos de bits que o computador entende e obedece, podem ser imaginadas como números. Por exemplo, os bits
dizem ao computador para somar dois números. O Capítulo 2 explica por que usamos números para instruções e dados; não queremos roubar o brilho desse capítulo, mas usar números para instruções e dados é um dos conceitos básicos da computação.
dígito binário Também chamado bit. Um dos dois números na base 2 (0 ou 1) que são os componentes básicos da informação.
Instrução Um comando que o hardware do computador entende e obedece. Os primeiros programadores se comunicavam com os computadores em números binários, mas isso era tão maçante que rapidamente inventaram novas notações mais parecidas com a maneira como os humanos pensam. No início, essas notações eram traduzidas para binário manualmente, mas esse processo
ainda era cansativo. Usando a própria máquina para ajudar a programá-la, os pioneiros inventaram programas que traduzem da notação simbólica para binário. O primeiro desses programas foi chamado de montador (assembler). Esse programa traduz uma versão simbólica de uma instrução para uma versão binária. Por exemplo, o programador escreveria
e o montador traduziria essa notação como
montador (assembler) Um programa que traduz uma versão simbólica de instruções para a versão binária. Essa instrução diz ao computador para somar dois números, A e B. O nome criado para essa linguagem simbólica, ainda em uso hoje, é linguagem assembly. Em contraste, a linguagem binária que a máquina entende é a linguagem de máquina.
linguagem assembly Uma representação simbólica das instruções de máquina.
linguagem de máquina Uma representação binária das instruções de máquina. Embora seja um fantástico avanço, a linguagem assembly ainda está longe da notação que um cientista gostaria de usar para simular fluxos de fluidos ou que um contador poderia usar para calcular seus saldos de contas. A linguagem assembly requer que o programador escreva uma linha para cada instrução que a máquina seguirá, obrigando o programador a pensar como a máquina.
A descoberta de que um programa poderia ser escrito para traduzir uma linguagem mais poderosa em instruções de computador foi um dos mais importantes avanços nos primeiros dias da computação. Os programadores atuais devem sua produtividade – e sua sanidade mental – à criação de linguagens de programação de alto nível e de compiladores que traduzem os programas escritos nessas linguagens em instruções. A Figura 1.4 mostra os relacionamentos entre esses programas e linguagens, que são outros exemplos do poder da abstração.
FIGURA 1.4 Programa em C compilado para assembly e depois montado em linguagem de máquina. Embora a tradução de linguagem de alto nível para a linguagem de máquina seja mostrada em duas etapas, alguns compiladores removem a fase intermediária e produzem linguagem de máquina diretamente. Essas linguagens e esse programa são analisados com mais detalhes no Capítulo 2.
linguagem de programação de alto nível Uma linguagem portável, como C, C++, Java ou Visual Basic, composta de palavras e notação algébrica, que pode ser traduzida por um compilador para a linguagem assembly. Um compilador permite que um programador escreva esta expressão em linguagem de alto nível:
O compilador compilaria isso na seguinte instrução em assembly:
Como podemos ver, o montador (assembler) traduziria essa instrução para a instrução binária, que diz ao computador para somar os dois números, A e B. As linguagens de programação de alto nível oferecem vários benefícios importantes. Primeiro, elas permitem que o programador pense em uma linguagem mais natural, usando palavras em inglês e notação algébrica, resultando em programas que se parecem muito mais com texto do que com tabelas de símbolos enigmáticos (Figura 1.4). Além disso, elas permitem que linguagens sejam projetadas de acordo com o uso pretendido. É por isso que a linguagem Fortran foi projetada para computação científica, Cobol para processamento de dados comerciais, Lisp para manipulação de símbolos e assim por diante. Há também linguagens específicas de domínio para grupos ainda mais estreitos de usuários, como aqueles interessados em simulação de fluidos, por exemplo. A segunda vantagem das linguagens de programação é a maior produtividade do programador. Uma das poucas áreas em que existe consenso no desenvolvimento de software é que necessita-se de menos tempo para desenvolver programas quando são escritos em linguagens que exigem menos linhas para expressar uma ideia. A concisão é uma clara vantagem das linguagens de alto nível em relação à linguagem assembly. A última vantagem é que as linguagens de programação permitem que os programas sejam independentes do computador no qual elas são desenvolvidas, já que os compiladores e montadores podem traduzir programas de linguagem de alto nível para instruções binárias de qualquer máquina. Essas três vantagens são tão fortes que, atualmente, pouca programação é realizada em assembly.
1.4. Sob as tampas Agora que olhamos por trás do programa para descobrir como ele funciona, vamos abrir a tampa do computador para aprender sobre o hardware dentro dele. O hardware de qualquer computador realiza as mesmas funções básicas: entrada, saída, processamento e armazenamento de dados. A forma como essas funções são realizadas é o principal tema deste livro, e os capítulos subsequentes lidam com as diferentes partes destas quatro tarefas. Quando tratamos de um aspecto importante neste livro, tão importante que esperamos que você se lembre dele para sempre, nós o enfatizamos identificando-o como um item “Colocando em perspectiva”. Há aproximadamente uma dúzia desses itens no livro; o primeiro descreve os cinco componentes de um computador que realizam as tarefas de entrada, saída, processamento e armazenamento de dados. Dois dos principais componentes dos computadores são: os dispositivos de entrada, como o teclado e o mouse, e os dispositivos de saída, como a caixa de som. Como o nome sugere, a entrada alimenta o computador, e a saída é o resultado da computação, enviado para o usuário. Alguns dispositivos, como redes sem fio, fornecem tanto entrada quanto saída para o computador.
dispositivo de entrada Um mecanismo por meio do qual o computador é alimentado com informações, como o teclado e o mouse.
dispositivo de saída Um mecanismo que transmite o resultado de uma computação para o usuário ou para outro computador. Os capítulos 5 e 6 descrevem dispositivos de entrada e saída (E/S) em mais detalhes, mas vamos dar um passeio preliminar pelo hardware do computador, começando com os dispositivos de E/S externos.
Colocando em perspectiva Os cinco componentes de um computador são: entrada, saída, memória,
caminho de dados e controle; os dois últimos, às vezes, são combinados e chamados de processador. A Figura 1.5 mostra a organização padrão de um computador. Essa organização é independente da tecnologia de hardware: você pode classificar cada parte de cada computador, antigos ou atuais, em uma dessas cinco categorias. Para ajudar a manter tudo isso em perspectiva, os cinco componentes de um computador são mostrados na primeira página dos capítulos seguintes, com a parte relativa ao capítulo destacada.
FIGURA 1.5 A organização de um computador, mostrando os cinco componentes clássicos. O processador obtém instruções e dados da memória. A entrada escreve dados na memória e a saída lê os dados desta. O controle envia os sinais que determinam as operações do caminho de dados, da memória, da entrada e da saída.
Através do espelho Pela tela do computador aterrissei um avião no pátio de uma transportadora, observei uma partícula nuclear colidir com uma fonte potencial, voei em um foguete, quase na velocidade da luz, e vi um computador revelar seus sistemas mais íntimos. Ivan Sutherland, o “pai” da computação gráfica, Scientific American, 1984
Talvez o dispositivo de E/S mais fascinante seja o monitor gráfico. A maioria dos dispositivos móveis pessoais utiliza monitores de cristal líquido (LCDs) para obterem uma tela fina, com baixa potência. O LCD não é a fonte da luz; em vez disso, ele controla a transmissão da luz. Um LCD típico inclui moléculas em forma de bastão em um líquido que forma uma hélice giratória que encurva a luz que entra na tela, de uma fonte de luz atrás da tela ou, menos frequentemente, da luz refletida. Os bastões se esticam quando uma corrente é aplicada e não encurvam mais a luz. Como o material de cristal líquido está entre duas telas polarizadas a 90 graus, a luz não pode passar a não ser que esteja encurvada. Hoje, a maioria dos monitores LCD utiliza uma matriz ativa que tem uma minúscula chave de transistor em cada pixel para controlar a corrente com precisão e gerar imagens mais nítidas. Uma máscara vermelha-verde-azul associada a cada ponto na tela determina a intensidade dos três componentes de cor na imagem final; em um LCD de matriz ativa colorida, existem três chaves de transistores em cada ponto.
monitor de cristal líquido Uma tecnologia de vídeo usando uma fina camada de polímeros líquidos que podem ser usados para transmitir ou bloquear a luz conforme uma corrente seja ou não aplicada.
monitor de matriz ativa Um monitor de cristal líquido usando um transistor para controlar a transmissão da luz em cada pixel individual. A imagem é composta de uma matriz de elementos de imagem, ou pixels, que
podem ser representados como uma matriz de bits, chamada mapa de bits, ou bitmap. Dependendo do tamanho da tela e da resolução, o tamanho da matriz de vídeo variava de 1024 × 768 a 2048 × 1536. Um monitor colorido pode usar 8 bits para cada uma das três cores primárias (vermelho, verde e azul), totalizando 24 bits por pixel, permitindo que milhões de cores diferentes sejam exibidas.
pixel O menor elemento individual da imagem. A tela é composta de centenas de milhares a milhões de pixels, organizados em uma matriz. O suporte de hardware do computador para a utilização de gráficos consiste, principalmente, em um buffer de atualização de varredura ou buffer de quadros, para armazenar o mapa de bits. A imagem a ser representada na tela é armazenada no buffer de quadros, e o padrão de bits de cada pixel é lido para o monitor gráfico a uma certa taxa de atualização. A Figura 1.6 mostra um buffer de quadros com 4 bits por pixel.
FIGURA 1.6 Cada coordenada no buffer de quadros à esquerda determina o tom da coordenada correspondente para o monitor TRC de varredura à direita. O pixel (X0, Y0) contém o padrão de bits 0011, que, na tela, é um tom de cinza mais claro do que o padrão de bits 1101 no pixel (X1, Y1).
O objetivo do mapa de bits é representar fielmente o que está na tela. As dificuldades dos sistemas gráficos surgem porque o olho humano é muito bom em detectar até mesmo as mais sutis mudanças na tela.
Touchscreen Embora os PCs também usem monitores LCD, os tablets e smartphones da era pós-PC substituíram o mouse e o teclado por telas sensíveis ao toque, que tem a maravilhosa vantagem da interface do usuário para que este aponte diretamente para o que está interessado, em vez de fazer isso indiretamente com um mouse. Embora existam diversas maneiras de implementar uma tela sensível ao toque, muitos tablets hoje utilizam a sensação capacitiva. Como as pessoas são condutores elétricos, se um isolante como o vidro for coberto com um condutor transparente, o toque distorce o campo eletrostático da tela, que resulta em uma mudança na capacitância. Essa tecnologia pode permitir múltiplos toques simultaneamente, o que permite gestos que possam levar a interfaces de usuário atraentes.
Abrindo o gabinete A Figura 1.7 mostra o conteúdo do computador tablet iPad 2, da Apple. Não é surpresa que, dos cinco componentes clássicos do computador, a E/S domine esse dispositivo de leitura. A lista de dispositivos de E/S inclui uma tela LCD capacitiva multitoque, câmera frontal, câmera traseira, microfone, conector de headphone, alto-falantes, acelerômetro, giroscópio, rede Wi-Fi e rede Bluetooth. O caminho de dados, o controle e a memória são uma minúscula parte dos componentes.
FIGURA 1.7 Componentes do Apple iPad 2 A1395. O fundo metálico do iPad (com o logo da Apple invertido no meio) está no centro. No topo está a tela multitoque capacitiva e o visor LCD. No canto direito está a bateria de polímero de 3,8 V e 25 W, que consiste em três capas de células de Li-ion e oferece 10 horas de vida da bateria. No canto esquerdo está o quadro metálico que conecta a LCD ao fundo do iPad. Os pequenos componentes em torno do fundo de metal no centro são o que consideramos o computador; eles normalmente têm a forma de um L para que se ajustem de modo compacto dentro da capa ao lado da bateria. A Figura 1.8 mostra uma visão de perto da placa em forma de L no canto inferior esquerdo da capa metálica, que é a placa do circuito impresso lógico, que contém o processador e a memória. O minúsculo retângulo abaixo da placa lógica contém um chip que fornece a comunicação sem fio: Wi-Fi, Bluetooth e sintonizador de FM. Ele se encaixa em um pequeno conector no canto inferior esquerdo da placa lógica. Perto do canto superior esquerdo da capa existe outro
componente em forma de L, que é a montagem da câmera frontal, que inclui a câmera, conector do headphone e microfone. Perto do canto superior direito da capa está a placa contendo o controle de volume e o botão de silêncio e bloqueio de rotação de tela, junto com um giroscópio e acelerômetro. Esses dois últimos chips se combinam para permitir que o iPad reconheça o movimento em 6 eixos. O pequeno retângulo perto dele é a câmera traseira. Perto do canto inferior direito da capa está a montagem do alto-falante em forma de L. O cabo no fundo é o conector entre a placa lógica e a placa de controle da câmera/volume. A placa entre o cabo e a montagem do altofalante é o controlador para a tela sensível capacitiva. (Cortesia da iFixit, www.ifixit.com)
Os pequenos retângulos na Figura 1.8 contêm os dispositivos que impulsionam nossa tecnologia avançada, os circuitos integrados, apelidados de chips. O pacote A5 visto no meio da Figura 1.8 contém dois processadores ARM que operam com uma taxa de clock de 1 GHz. O processador é a parte ativa da placa, que segue rigorosamente as instruções de um programa. Ele soma e testa números, sinaliza dispositivos de E/S para serem ativados e assim por diante. Ocasionalmente, as pessoas chamam o processador de CPU, que significa o termo pomposo unidade central de processamento.
FIGURA 1.8 A placa lógica do Apple iPad 2 na Figura 1.7. A foto destaca cinco circuitos integrados. O circuito integrado grande no meio é o chip A5 da Apple, que contém os núcleos do processador ARM dual que rodam a 1 GHz, bem como a memória principal de 512 MB dentro do pacote. A Figura 1.9 mostra uma fotografia do chip processador dentro do pacote A5. O chip de tamanho semelhante à esquerda é o chip de memória flash de 32 GB para o armazenamento não volátil. Há um espaço vazio entre os dois chips, onde um segundo chip flash pode ser
instalado para dobrar a capacidade de armazenamento do iPad. Os chips à direita do A5 incluem: controlador de potência e chips controladores de E/S. (Cortesia da iFixit, www.ifixit.com)
circuito integrado Também chamado chip, é um dispositivo que combina de dezenas a milhões de transistores.
unidade central de processamento (CPU) Também chamada de processador. A parte ativa do computador, que contém o caminho de dados, e o controle, que soma, testa números e sinaliza aos dispositivos de E/S para que sejam ativados etc. Penetrando ainda mais no hardware, a Figura 1.9 revela detalhes de um microprocessador. O processador contém logicamente dois componentes principais: o caminho de dados e o controle, correspondendo, respectivamente, aos músculos e ao cérebro do processador. O caminho de dados realiza as operações aritméticas, e o controle diz ao caminho de dados, à memória e aos dispositivos de E/S o que fazer de acordo com as instruções do programa. O Capítulo 4 explica o caminho de dados e o controle para um projeto de desempenho mais alto.
FIGURA 1.9 O circuito integrado do processador dentro do pacote A5. O tamanho do chip é 12,1 por 10,1 mm, e foi fabricado originalmente em um processo de 45 nm (Seção 1.5). Ele possui dois processadores ARM idênticos, ou núcleos, na metade esquerda do chip, e uma unidade de processamento gráfico (GPU) PowerVR com quatro caminhos de dados no quadrante superior esquerdo. No canto inferior esquerdo dos núcleos ARM estão as interfaces com a memória principal
(DRAM). (Cortesia da Chipworks, www.chipworks.com)
caminho de dados O componente do processador que realiza operações aritméticas.
controle O componente do processador que comanda o caminho de dados, a memória e os dispositivos de E/S de acordo com as instruções do programa. O pacote A5 na Figura 1.8 também inclui dois chips de memória, cada um com 2 gibibits de capacidade, fornecendo assim 512 MiB. A memória é onde os programas são mantidos quando estão sendo executados; ela também contém os dados necessários aos programas em execução. A memória é constituída de chips DRAM. DRAM significa RAM dinâmica (Dynamic Random Access Memory). Várias DRAMs são usadas em conjunto para conter as instruções e os dados de um programa. Ao contrário das memórias de acesso sequencial, como as fitas magnéticas, a parte RAM do termo DRAM significa que os acessos à memória levam o mesmo tempo, independentemente da parte da memória lida.
memória A área de armazenamento temporária em que os programas são mantidos quando estão sendo executados e que contém os dados necessários para os programas em execução.
Dynamic Random Access Memory (DRAM) Memória construída como um circuito integrado para fornecer acesso aleatório a qualquer local. Os tempos de acesso são de 50 nanossegundos e o custo por gigabyte em 2012 era de US$ 5 a US$ 10. Descer até as profundezas de qualquer componente de hardware revela os interiores da máquina. Dentro do processador, existe outro tipo de memória – a memória cache. A memória cache consiste em uma memória pequena e rápida que age como um buffer para a memória DRAM. (A definição não-técnica de
cache é um lugar seguro para esconder as coisas.) A cache é construída usando uma tecnologia de memória diferente, a RAM estática – Static Random Access Memory (SRAM). A SRAM é mais rápida, mas menos densa e, portanto, mais cara do que a DRAM (Capítulo 5). SRAM e DRAM são duas camadas da hierarquia de memória.
memória cache Uma memória pequena e rápida que age como um buffer para uma memória maior e mais lenta.
Static Random Access Memory (SRAM) Também uma memória montada como um circuito integrado, porém mais rápida e menos densa que a DRAM. Como mencionado anteriormente, uma das grandes ideias para melhorar o projeto é a abstração. Uma das abstrações mais importantes é a interface entre o hardware e o software de nível mais baixo. Em decorrência de sua importância, ela recebe um nome especial: a arquitetura do conjunto de instruções, ou simplesmente arquitetura, de uma máquina. A arquitetura do conjunto de
instruções inclui tudo o que os programadores precisam saber para fazer um programa em linguagem de máquina binária funcionar corretamente, incluindo instruções, dispositivos de E/S etc. Em geral, o sistema operacional encapsulará os detalhes da E/S, da alocação de memória e de outras funções de baixo nível do sistema, para que os programadores das aplicações não precisem se preocupar com esses detalhes. A combinação do conjunto de instruções básico e da interface do sistema operacional fornecida para os programadores das aplicações é chamada de interface binária de aplicação (ABI).
arquitetura do conjunto de instruções Também chamada simplesmente de arquitetura. Uma interface abstrata entre o hardware e o software de nível mais baixo de uma máquina que abrange todas as informações necessárias para escrever um programa em linguagem de máquina que será corretamente executado, incluindo instruções, registradores, acesso à memória, E/S e assim por diante.
interface binária de aplicação (ABI) A parte voltada ao usuário do conjunto de instruções mais as interfaces do sistema operacional usadas pelos programadores das aplicações. Define um padrão para a portabilidade binária entre computadores. Uma arquitetura do conjunto de instruções permite aos projetistas de computador falarem sobre funções, independentemente do hardware que as realiza. Por exemplo, podemos falar sobre as funções de um relógio digital (marcar as horas, exibir as horas, definir o alarme) sem falar sobre o hardware do relógio (o cristal de quartzo, os visores de LEDs, os botões plásticos). Os projetistas de computador distinguem entre a arquitetura e uma implementação da arquitetura da mesma maneira: uma implementação é o hardware que obedece à abstração da arquitetura. Esses conceitos nos levam a outra seção “Colocando em perspectiva”.
implementação Hardware que obedece à abstração de uma arquitetura.
Colocando em perspectiva Tanto o hardware quanto o software consistem em camadas hierárquicas usando abstração, com cada camada inferior ocultando detalhes do nível acima. Uma interface-chave entre os níveis de abstração é a arquitetura do conjunto de instruções — a interface entre o hardware e o software de baixo nível. Essa interface abstrata permite que muitas implementações com custo e desempenho variáveis executem um software idêntico.
Um lugar seguro para os dados Até agora, vimos como os dados são inseridos, processados e exibidos. Entretanto, se houvesse uma interrupção no fornecimento de energia, tudo seria perdido porque a memória dentro do computador é volátil – ou seja, quando perde energia, ela se esquece. Por outro lado, um DVD não se esquece do filme quando você desliga o aparelho de DVD e, portanto, é uma tecnologia de memória não volátil.
memória volátil Armazenamento, como a DRAM, que conserva os dados apenas enquanto estiver recebendo energia.
memória não volátil Uma forma de memória que conserva os dados mesmo na ausência de energia e que é usada para armazenar programas entre execuções. Um disco de DVD é não volátil. Para distinguir entre a memória usada para armazenar dados e programas enquanto estão sendo executados e essa memória não volátil usada para armazenar programas entre as execuções, o termo memória principal ou memória primária é usado para o primeiro e o termo memória secundária é usado para o último. A memória secundária forma a próxima camada inferior da hierarquia de memória. As DRAMs dominam a memória principal desde 1975; e os discos magnéticos dominam a memória secundária há mais tempo ainda. Devido ao seu tamanho e formato, os dispositivos móveis pessoais utilizam memória flash, uma memória semicondutora não volátil, no lugar dos discos. A Figura 1.8 mostra o chip contendo a memória flash do iPad 2. Embora mais lenta que a DRAM, ela é muito mais barata, além de ser não volátil. Embora custando mais por bit do que os discos, ela é menor, vem em capacidades muito menores, é mais resistente e utiliza menos energia do que os discos. Logo, a memória flash é a memória secundária padrão para os PMDs. Infelizmente, diferente de discos e da DRAM, os bits da memória flash se desgastam após 100.000 a 1.000.000 de escritas. Assim, os sistemas de arquivos precisam acompanhar o número de escritas e ter uma estratégia para evitar o desgaste do armazenamento, por exemplo, movendo dados mais populares. O Capítulo 5 descreve os discos e a memória flash com mais detalhes.
memória principal Também chamada memória primária. A memória usada para armazenar os programas enquanto estão sendo executados; normalmente consiste na DRAM nos computadores atuais.
memória secundária Memória não volátil usada para armazenar programas e dados entre execuções; normalmente consiste em memória flash nos PMDs e discos magnéticos nos servidores.
disco magnético (também chamado de disco rígido) Uma forma de memória secundária não volátil composta por discos giratórios cobertos com um material de gravação magnético. Por serem dispositivos mecânicos rotativos, os tempos de acesso são cerca de 5 a 20 milissegundos e o custo por gigabyte em 2012 era de US$ 0,05 a US$ 0,10.
memória flash Uma memória semicondutora não volátil. Ela é mais barata e mais lenta que a
DRAM, porém mais cara por bit e mais rápida que os discos magnéticos. Os tempos de acesso são cerca de 5 a 50 microssegundos, e o custo por gigabyte em 2012 era de US$ 0,75 a US$ 1,00.
Comunicação com outros computadores Explicamos como podemos realizar entrada, processamento, exibição e armazenamento de dados, mas ainda falta um item que é encontrado nos computadores modernos: as redes de computadores. Exatamente como o processador mostrado na Figura 1.5, que está conectado à memória e aos dispositivos de E/S, as redes conectam computadores inteiros, permitindo que os usuários estendam a capacidade de computação incluindo a comunicação. As redes se tornaram tão comuns que, hoje, constituem o backbone (espinha dorsal) dos sistemas de computação atuais; uma máquina nova sem uma interface de rede opcional seria ridicularizada. Os computadores em rede possuem diversas vantagens importantes: ▪ Comunicação: as informações são trocadas entre computadores em altas velocidades. ▪ Compartilhamento de recursos: em vez de cada máquina ter seus próprios dispositivos de E/S, os dispositivos podem ser compartilhados pelos computadores que compõem a rede. ▪ Acesso remoto: conectando computadores por meio de longas distâncias, os usuários não precisam estar próximo ao computador que estão usando. As redes variam em tamanho e desempenho, com o custo da comunicação aumentando de acordo com a velocidade de comunicação e a distância em que as informações viajam. Talvez, o tipo de rede mais comum seja a Ethernet. Sua extensão é limitada em aproximadamente um quilômetro, transferindo até 40 gigabits por segundo. Sua extensão e velocidade tornam a Ethernet útil para conectar computadores no mesmo andar de um prédio; portanto, esse é um exemplo do que é chamado genericamente de rede local (LAN). As redes locais são interconectadas com switches que também podem fornecer serviços de roteamento e segurança. As redes remotas (WAN) atravessam continentes e são a espinha dorsal da Internet, que é o suporte da World Wide Web. Elas costumam ser baseadas em cabos de fibra ótica e são alugadas de empresas de telecomunicações.
rede local (LAN)
Uma rede projetada para transportar dados dentro de uma área geograficamente restrita, em geral, dentro de um mesmo prédio.
rede remota (WAN) Uma rede estendida por centenas de quilômetros, que pode atravessar continentes. As redes mudaram a cara da computação nos últimos 30 anos, por se tornarem muito mais comuns e aumentarem drasticamente o desempenho. Na década de 1970, poucas pessoas tinham acesso ao correio eletrônico (e-mail). A Internet e a Web não existiam, e a remessa física de fitas magnéticas era o meio principal de transferir grandes quantidades de dados entre dois locais. Nessa década, as redes locais eram quase inexistentes e as poucas redes remotas existentes tinham capacidade limitada e acesso restrito. À medida que a tecnologia de redes avançou, ela se tornou bastante barata e obteve uma capacidade de transmissão muito mais alta. Por exemplo, a primeira tecnologia de rede local a ser padronizada, desenvolvida há cerca de 30 anos, foi uma versão da Ethernet que tinha uma capacidade máxima (também chamada de largura de banda) de 10 milhões de bits por segundo, normalmente compartilhada por dezenas, se não centenas, de computadores. Hoje, a tecnologia de rede local oferece uma capacidade de transmissão de 1 a 40 gigabits por segundo, em geral compartilhada por, no máximo, alguns computadores. A tecnologia de comunicação ótica permitiu um crescimento semelhante na capacidade das redes remotas de centenas de kilobits até gigabits, e de centenas de computadores conectados a uma rede mundial até milhões de computadores conectados. Essa combinação do drástico aumento no emprego das redes e em sua capacidade, tornaram a tecnologia de redes o ponto central para a revolução da informação nos últimos 30 anos. Na última década, outra inovação na tecnologia de redes está reformulando a maneira como os computadores se comunicam. A tecnologia sem fio se tornou amplamente utilizada, o que permitiu a era pós-PC. A capacidade de criar um rádio com a mesma tecnologia de semicondutor de baixo custo (CMOS) usada para memória e microprocessadores permitiu uma significativa melhoria no preço, levando a uma explosão no consumo. As tecnologias sem fio disponíveis atualmente, conhecidas pelo padrão IEEE 802.11, permitem velocidades de transmissão de 1 a quase 100 milhões de bits por segundo. A tecnologia sem fio
é um pouco diferente das redes baseadas em fios, já que todos os usuários em uma área próxima compartilham as ondas aéreas.
Verifique você mesmo ▪ A memória semicondutora DRAM, a memória flash e o armazenamento de disco diferem significativamente. Para cada tecnologia, descreva a principal diferença quanto a cada um dos seguintes aspectos: volatilidade, tempo de acesso relativo aproximado e custo relativo aproximado em comparação com a DRAM.
1.5. Tecnologias para construção de processadores e memórias Os processadores e a memória melhoraram em uma velocidade espantosa porque os projetistas de computadores, durante muito tempo, abraçaram o que havia de mais moderno na tecnologia eletrônica a fim de tentar vencer a corrida para projetar um computador melhor. A Figura 1.10 mostra as tecnologias usadas ao longo do tempo, com uma estimativa do desempenho relativo por custo unitário para cada tecnologia. Como essa tecnologia esboça o que os computadores serão capazes de fazer e a velocidade com que irão evoluir, acreditamos que todos os profissionais de computação devem estar familiarizados com os fundamentos dos circuitos integrados.
FIGURA 1.10 Desempenho relativo, por custo unitário, das tecnologias usadas nos computadores ao longo do tempo. Fonte: Computer Museum, Boston.
Um transistor é simplesmente uma chave liga/desliga controlada por eletricidade. O circuito integrado (CI) combinou dezenas a centenas de transistores em um único chip. Quando Gordon Moore previu a duplicação contínua dos recursos, ele estava prevendo a taxa de crescimento do número de transistores por chip. Para descrever o incrível aumento no número de transistores de centenas para milhões, a característica escala muito grande é acrescentado ao termo, criando a abreviação VLSI (de circuito Very Large Scale Integrated).
transistor Uma chave liga/desliga controlada por um sinal elétrico.
circuito Very Large Scale Integrated (VLSI) Um dispositivo com centenas de milhares a milhões de transistores. Essa taxa de integração crescente tem se mantido notavelmente estável. A Figura 1.11 mostra o crescimento na capacidade da DRAM desde 1977. Durante décadas, a indústria quadruplicou consistentemente a capacidade a cada três anos, resultando em um aumento de mais de 16.000 vezes!
FIGURA 1.11 Crescimento da capacidade por chip de DRAM ao longo do tempo. O eixo y é medido em kibibits (210 bits). A indústria de DRAM quadruplicou a capacidade a cada quase três anos, um aumento de 60% por ano, durante 20 anos. Nos últimos anos, essa taxa diminuiu um pouco e está próximo do dobro a cada dois anos.
Para entender como fabricar circuitos integrados, começamos do início. A fabricação de um chip começa com o silício, uma substância encontrada na areia. Como o silício não conduz bem a eletricidade, ele é chamado de semicondutor. Com um processo químico especial, é possível acrescentar ao silício materiais que permitem que minúsculas áreas se transformem em um entre três dispositivos: ▪ Excelentes condutores de eletricidade (usando fios microscópicos de cobre ou alumínio) ▪ Excelentes isolantes de eletricidade (como cobertura plástica ou vidro) ▪ Áreas que podem conduzir ou isolar sob condições especiais (como uma chave)
silício Um elemento natural que é um semicondutor.
semicondutor Uma substância que não conduz eletricidade muito bem. Os transistores se encaixam na última categoria. Um circuito VLSI, então, simplesmente consiste em bilhões de combinações de condutores, isolantes e chaves, fabricados em um único e pequeno pacote. O processo de fabricação dos circuitos integrados é decisivo para o custo dos chips e, consequentemente, fundamental para os projetistas de computador. A Figura 1.12 mostra esse processo. O processo inicia com um lingote de cristal de silício, que se parece com uma salsicha gigante. Hoje, os lingotes possuem de 20 a 30 cm de diâmetro e cerca de 30 a 60 cm de comprimento. Um lingote é finamente fatiado em wafers, com até 0,25 cm de espessura. Esses wafers passam por uma série de etapas de processamento, durante as quais são depositados padrões de elementos químicos em cada lâmina, criando os transistores, os condutores e os isolantes discutidos anteriormente. Os circuitos integrados de hoje contêm apenas uma camada de transistores, mas podem ter de dois a oito níveis de condutor de metal, separados por camadas de isolantes.
FIGURA 1.12 Processo de fabricação de um chip. Após ser fatiado de um lingote de silício, os wafers virgens passam por 20 a 40 passos para criar wafers padronizados (Figura 1.13). Esses wafers padronizados são testados com um testador de wafers e é criado um mapa das partes boas. Depois, os wafers são divididos em dies (moldes) (Figura 1.9). Nessa figura, um wafer produziu 20 dies, dos quais 17 passaram no teste. (X significa que o die está ruim.) O aproveitamento de dies bons, neste caso, foi de 17/20, ou 85%. Esses dies bons são soldados a encapsulamentos e testados outra vez antes de serem remetidos para os clientes. Um die encapsulado ruim foi encontrado nesse teste final.
lingote de cristal de silício Uma barra composta de um cristal de silício que possui entre 20 e 30cm de diâmetro e cerca de 30 a 60cm de comprimento.
wafer Uma fatia de um lingote de silício de não mais que 2,5mm de espessura, usada para criar chips. Uma única imperfeição microscópica no wafer propriamente dito, ou em uma das dezenas de passos da aplicação dos padrões, pode resultar na falha dessa área do wafer. Esses defeitos, como são chamados, tornam praticamente impossível fabricar um wafer perfeito. A estratégia mais simples para lidar com a imperfeição é colocar muitos componentes independentes em um único wafer. O wafer com os padrões é, então, cortado em seções individuais desses componentes, chamados dies, mais informalmente conhecidos como chips. A Figura 1.13 é uma fotografia de um wafer com microprocessadores antes de serem cortados; anteriormente, a Figura 1.9 mostrou um die individual do microprocessador e seus principais componentes.
FIGURA 1.13 Um wafer de 300 mm de diâmetro dos chips Intel Core i7 (Cortesia da Intel). O número de dies nesse wafer de 300 mm em 100% de aproveitamento é 280, cada um com 20,7 por 10,5 mm. As várias dezenas de chips parcialmente arredondados nas bordas do wafer são inúteis; são incluídas porque é mais fácil criar as máscaras usadas para imprimir os padrões desejados ao silício. Esse die usa uma tecnologia de 32 nanômetros, o que significa que os menores recursos possuem um tamanho de aproximadamente 32 nm, embora normalmente sejam um pouco menores do que o tamanho real do recurso, que se refere ao tamanho dos transistores como “desenhados” versus o tamanho final fabricado.
defeito Uma imperfeição microscópica em um wafer ou nos passos da aplicação dos padrões que pode resultar na falha do die que contém esse defeito.
dies As seções retangulares individuais cortadas de um wafer, mais informalmente conhecidos como chips. Cortar os wafers em seções permite descartar apenas aqueles dies que possuem falhas, em vez do wafer inteiro. Esse conceito é quantificado pelo aproveitamento de um processo, definido como a porcentagem de dies bons do número total de dies em um wafer.
aproveitamento A porcentagem de dies bons do número total de dies em um wafer. O custo de um circuito integrado sobe rapidamente conforme aumenta o tamanho do die, em razão do aproveitamento mais baixo e do menor número de dies que pode caber em um wafer. Para reduzir o custo, um die grande normalmente é “encolhido” usando um processo da próxima geração, que incorpora tamanhos menores de transistores e de fios. Isso melhora o aproveitamento e o número de dies por wafer. Um processo de 32 nanômetros (nm) era comum em 2012, o que significa basicamente que o menor tamanho de recurso no die é 32 nm. Tendo dies bons, eles são conectados aos pinos de entrada/saída de um encapsulamento usando um processo chamado soldagem. Essas peças encapsuladas são testadas uma última vez, já que podem ocorrer erros no encapsulamento, e são remetidas aos clientes.
Detalhamento O custo de um circuito integrado pode ser expresso em três equações simples:
A primeira equação é simples de se derivar. A segunda é uma aproximação, pois não subtrai a área perto da borda do wafer arredondado que não pode acomodar os dies retangulares (Figura 1.13). A equação final é baseada nas observações empíricas dos aproveitamentos nas fábricas de circuito integrado, com o expoente relacionado ao número de etapas de processamento crítico. Logo, dependendo da taxa de defeito e do tamanho do die e wafer, os custos geralmente não são lineares em relação à área do die.
Verifique você mesmo Um fator chave para determinar o custo de um circuito integrado é o volume. Quais dos seguintes são motivos pelos quais um chip fabricado em grande volume custaria menos? 1. Com grandes volumes, o processo de manufatura pode ser ajustado para um projeto em particular, aumentando o aproveitamento. 2. É menos trabalhoso projetar uma peça com alto volume do que uma peça com baixo volume. 3. As máscaras usadas para criar o chip são caras, de modo que o custo por chip é mais baixo para volumes mais altos. 4. Os custos de desenvolvimento da engenharia são altos e, em grande parte, não dependem do volume; assim, o custo de desenvolvimento por die é mais baixo com peças de alto volume. 5. Peças de alto volume normalmente possuem menores tamanhos de die do que as peças de baixo volume e, portanto, possuem maior
aproveitamento por wafer.
1.6. Desempenho Avaliar o desempenho dos computadores pode ser desafiador. A escala e a complexidade dos sistemas de software modernos, junto com a grande variedade de técnicas de melhoria de desempenho empregadas por projetistas de hardware, tornaram a avaliação do desempenho muito mais difícil. Ao tentar escolher entre diferentes computadores, o desempenho é um atributo importante. Comparar e avaliar com precisão diferentes computadores é crítico para compradores e por consequência, também para os projetistas. O pessoal que vende computadores também sabe disso. Normalmente, os vendedores desejam que você veja seu computador da melhor maneira possível, não importa se isso reflete ou não as necessidades da aplicação do comprador. Logo, ao escolher um computador, é importante entender como medir melhor o desempenho e as limitações das medições de desempenho. O restante desta seção descreve diferentes maneiras como o desempenho pode ser determinado; depois, descrevemos as métricas para avaliar o desempenho do ponto de vista de um usuário do computador e de um projetista. Também analisamos como essas métricas estão relacionadas e apresentamos a equação clássica de desempenho do processador, que usaremos no decorrer do texto.
Definindo o desempenho Quando dizemos que um computador tem melhor desempenho que outro, o que queremos dizer? Embora essa pergunta possa parecer simples, uma analogia com aviões de passageiros mostra como a questão de desempenho pode ser sutil. A Figura 1.14 mostra alguns aviões de passageiros típicos, juntamente com a velocidade de cruzeiro, autonomia e capacidade. Se você quisesse saber qual dos aviões nessa tabela tem o melhor desempenho, primeiro precisaríamos definir o desempenho. Por exemplo, considerando diferentes medidas de desempenho, vemos que o avião com a maior velocidade de cruzeiro é o Concorde (que saiu de serviço em 2003), o avião com a maior autonomia é o DC-8, e o avião com a maior capacidade é o 747.
FIGURA 1.14 A capacidade, autonomia e velocidade de uma série de aviões comerciais. A última coluna mostra a taxa com que o avião transporta passageiros, que é a capacidade vezes a velocidade de voo (ignorando a autonomia e os tempos de decolagem e pouso).
Vamos supor que o desempenho seja definido em termos de velocidade. Isso ainda deixa duas definições possíveis. Você poderia definir o avião mais rápido como aquele com a velocidade de voo mais alta, levando um único passageiro de um ponto para outro com o menor tempo. Porém, se você estivesse interessado em transportar 450 passageiros de um ponto para outro, o 747 certamente seria o mais rápido, como mostra a última coluna da figura. De modo semelhante, podemos definir o desempenho do computador de diferentes maneiras. Se você estivesse rodando um programa em dois computadores desktop diferentes, diria que o mais rápido é o computador que termina o trabalho primeiro. Se estivesse gerenciando um centro de dados com diversos servidores rodando tarefas submetidas por muitos usuários, você diria que o computador mais rápido é aquele que completasse o máximo de tarefas durante um dia. Como um usuário de computador individual, você está interessado em reduzir o tempo de resposta — o tempo entre o início e o término de uma tarefa — também conhecido como tempo de execução. Os gerentes de centro de dados normalmente estão interessados em aumentar o throughput ou largura de banda — a quantidade total de trabalho realizado em determinado tempo. Logo, na maioria dos casos, ainda precisaremos de diferentes métricas de desempenho, além de diferentes conjuntos de aplicações para avaliar computadores embutidos e de desktop, que são mais voltados para o tempo de resposta, contra servidores, que são mais voltados para o throughput.
tempo de resposta Também chamado tempo de execução. O tempo total exigido para o computador completar uma tarefa, incluindo acessos ao disco, acessos à memória, atividades de E/S, overhead do sistema operacional, tempo de
execução de CPU e assim por diante.
throughput Também chamado largura de banda. Outra medida de desempenho, é o número de tarefas completadas por unidade de tempo.
Throughput e tempo de resposta Exemplo As mudanças a seguir em um sistema de computador aumentam o throughput, diminuem o tempo de resposta ou ambos? 1. Substituir o processador em um computador por uma versão mais rápida. 2. Acrescentar processadores adicionais a um sistema que utiliza múltiplos processadores para tarefas separadas — por exemplo, busca na Web.
Resposta Diminuir o tempo de resposta quase sempre melhora o throughput. Logo, no caso 1, o tempo de resposta e o throughput são melhorados. No caso 2, ninguém realiza o trabalho mais rapidamente, de modo que somente o throughput aumenta. Porém, se a demanda para processamento no segundo caso fosse quase tão grande quanto o throughput, o sistema poderia forçar as solicitações a se enfileirarem. Nesse caso, aumentar o throughput também poderia melhorar o tempo de resposta, pois poderia reduzir o tempo de espera na fila. Assim, em muitos sistemas de computadores reais, mudar o tempo de execução ou o throughput normalmente afeta o outro. Na discussão sobre o desempenho dos computadores, vamos nos preocupar principalmente com o tempo de resposta nos primeiros capítulos. Para maximizar o desempenho, queremos minimizar o tempo de resposta ou o tempo de execução para alguma tarefa. Assim, podemos relacionar desempenho e tempo de execução para o computador X:
Isso significa que, para dois computadores X e Y, se o desempenho de X for maior que o desempenho de Y, temos
Ou seja, o tempo de execução em Y é maior que o de X, se X for mais rápido que Y. Na discussão de um projeto de computador, normalmente queremos relacionar o desempenho e dois computadores diferentes quantitativamente. Usaremos a frase “X é n vezes mais rápido que Y” — ou, de modo equivalente, “X tem n vezes a velocidade de Y” — para indicar
Se X for n vezes mais rápido que Y, então o tempo de execução em Y é n vezes maior do que em X:
Desempenho relativo Exemplo Se o computador A executa um programa em 10 segundos e o computador B executa o mesmo programa em 15 segundos, o quanto A é mais rápido que B?
Resposta Sabemos que A é n vezes mais rápido que B se
Assim, a razão de desempenho é
e A, portanto, é 1,5 vez mais rápido que B. No exemplo anterior, também poderíamos dizer que o computador B é 1,5 vez mais lento que o computador A, pois
significando que
Para simplificar, normalmente usaremos a terminologia mais rápido que quando tentamos comparar computadores quantitativamente. Como o desempenho e o tempo de execução são recíprocos, aumentar o desempenho requer diminuir o tempo de execução. Para evitar a confusão em potencial entre os termos aumentar e diminuir, normalmente dizemos “melhorar o desempenho” ou “melhorar o tempo de execução” quando queremos dizer “aumentar o desempenho” e “diminuir o tempo de execução”.
Medindo o desempenho O tempo é a medida de desempenho do computador: o computador que realiza a mesma quantidade de trabalho no menor tempo é o mais rápido. O tempo de execução do programa é medido em segundos por programa. Porém, o tempo pode ser definido de diferentes maneiras, dependendo do que contamos. A definição mais clara de tempo é chamada de tempo do relógio, tempo de resposta ou tempo decorrido. Esses termos significam o tempo total para completar uma tarefa, incluindo acessos ao disco, acessos à memória, atividades de entrada/saída (E/S), overhead do sistema operacional — tudo. Contudo, os computadores normalmente são compartilhados e um processador pode trabalhar em vários programas simultaneamente. Nesses casos, o sistema pode tentar otimizar o throughput em vez de tentar minimizar o tempo decorrido para um programa. Logo, normalmente queremos distinguir entre o tempo decorrido e o tempo que o processador está trabalhando em nosso favor. Tempo de execução de CPU, ou simplesmente tempo de CPU, que reconhece essa distinção, é o tempo que a CPU gasta computando para essa tarefa, e não inclui o tempo gasto esperando pela E/S ou pela execução de outros programas. (Lembre-se, porém, de que o tempo de resposta experimentado pelo usuário será o tempo decorrido do programa, e não o tempo de CPU.) O tempo de CPU pode ser dividido ainda mais em tempo de CPU gasto no programa, chamado tempo de CPU do usuário, e o tempo de CPU gasto no sistema operacional, realizando
tarefas em favor do programa, chamado tempo de CPU do sistema. A diferenciação entre o tempo de CPU do sistema e do usuário é difícil de se realizar com precisão, pois normalmente é difícil atribuir a responsabilidade pelas atividades do sistema operacional a um programa do usuário em vez do outro, e por causa das diferenças de funcionalidade entre os sistemas operacionais.
tempo de execução de CPU Também chamado tempo de CPU. O tempo real que a CPU gasta calculando para uma tarefa específica.
tempo de CPU do usuário O tempo de CPU gasto em um programa propriamente dito.
tempo de CPU do sistema O tempo de CPU gasto no sistema operacional realizando tarefas em favor do programa. Por uma questão de consistência, mantemos uma distinção entre o desempenho baseado no tempo decorrido e baseado no tempo de execução da CPU. Usaremos o termo desempenho do sistema para nos referirmos ao tempo decorrido em um sistema não carregado e desempenho da CPU para nos referirmos ao tempo de CPU do usuário. Vamos focalizar o desempenho da CPU neste capítulo, embora nossas discussões de como resumir o desempenho possam ser aplicadas às medições de tempo decorrido ou tempo de CPU.
Entendendo o desempenho do programa Diferentes aplicações são sensíveis a diferentes aspectos do desempenho de um sistema de computador. Muitas aplicações, especialmente aquelas rodando em servidores, dependem muito do desempenho da E/S, que, por sua vez, conta com o hardware e o software. O tempo decorrido total medido por um relógio comum é a medida de interesse. Em alguns ambientes de aplicação, o usuário pode se importar com o throughput, tempo de resposta ou uma combinação complexa dos dois (por exemplo, o throughput máximo com o
tempo de resposta, no pior caso). Para melhorar o desempenho de um programa, deve-se ter uma definição clara de qual métrica de desempenho interessa e depois prosseguir para procurar gargalos de desempenho medindo a execução do programa e procurando os prováveis gargalos. Nos próximos capítulos, vamos descrever como procurar gargalos e melhorar o desempenho em diversas partes do sistema. Embora, como usuários de computador, nos importemos com o tempo, quando examinamos os detalhes de um computador, é conveniente pensar sobre o desempenho em outras métricas. Em particular, os projetistas de computação podem querer pensar a respeito de um computador usando uma medida que se relaciona à velocidade com que o hardware pode realizar suas funções básicas. Quase todos os computadores são construídos usando-se um clock que determina quando os eventos ocorrem no hardware. Esses intervalos de tempo discretos são chamados de ciclos de clock (ou batidas, batidas de clock, períodos de clock, clocks, ciclos). Os projetistas referem-se à extensão de um período de clock como o tempo para um ciclo de clock completo (por exemplo, 250 picossegundos ou 250 ps) e como a taxa de clock (por exemplo, 4 gigahertz ou 4 GHz), que é o inverso do período de clock. Na próxima subseção, formalizaremos o relacionamento entre os ciclos de clock do projetista de hardware e os segundos do usuário do computador.
ciclo de clock Também chamado batida, batida de clock, período de clock, clock, ciclo. O tempo para um período de clock, normalmente do clock do processador, que trabalha a uma taxa constante.
período de clock A extensão de cada ciclo de clock.
Verifique você mesmo 1. Suponha que saibamos que uma aplicação que usa dispositivos pessoais móveis e a nuvem seja limitada pelo desempenho da rede. Para as mudanças a seguir, indique se: somente o throughput melhora, o tempo de resposta e o throughput melhoram, ou nenhum destes melhora.
a. Um canal de rede extra é acrescentado entre o PMD e a nuvem, aumentando o throughput total da rede e reduzindo o atraso para obter o acesso à rede (pois agora existem dois canais). b. O software de rede é melhorado, reduzindo assim o atraso na comunicação da rede, mas não aumentando o throughput. c. Mais memória é acrescentada ao computador. 2. O desempenho do computador C é quatro vezes mais rápido que o desempenho do computador B, que executa determinada aplicação em 28 segundos. Quanto tempo o computador C levará para executar esta aplicação?
Desempenho da CPU e seus fatores Usuários e projetistas normalmente examinam o desempenho usando diferentes métricas. Se pudéssemos relacionar essas diferentes métricas, poderíamos determinar o efeito de uma mudança de projeto sobre o desempenho experimentado pelo usuário. Como estamos interessados no desempenho da CPU neste ponto, a medida de desempenho final é o tempo de execução da CPU. Uma fórmula simples relaciona as métricas mais básicas (ciclos de clock e tempo do ciclo de clock) ao tempo da CPU:
Como alternativa, como a taxa de clock e o tempo do ciclo de clock são inversos,
Essa fórmula deixa claro que o projetista de hardware pode melhorar o desempenho reduzindo o número de ciclos de clock exigidos para um programa ou o tamanho do ciclo de clock. Conforme veremos em outros capítulos, os projetistas normalmente têm de escolher entre o número de ciclos de clock necessários para um programa e a extensão de cada ciclo. Muitas técnicas que diminuem o número de ciclos de clock podem também aumentar o tempo do
ciclo de clock.
Melhorando o desempenho Exemplo Nosso programa favorito executa em 10 segundos no computador A, que tem um clock de 2 GHz. Estamos tentando ajudar um projetista de computador a montar um computador B, que executará esse programa em 6 segundos. O projetista determinou que é possível haver um aumento substancial na taxa de clock, mas esse aumento afetará o restante do projeto da CPU, fazendo com que o computador B exija 1,2 vez a quantidade de ciclos de clock do computador A para esse programa. Que taxa de clock o projetista deve ter como alvo?
Resposta Vamos primeiro achar o número de ciclos de clock exigidos para o programa em A:
O tempo de CPU para B pode ser encontrado por meio desta equação:
Para executar o programa em 6 segundos, B deverá ter o dobro da taxa de clock de A.
Desempenho da instrução Essas equações de desempenho não incluíram qualquer referência ao número de instruções necessárias para o programa. Porém, como o compilador claramente gerou instruções para executar, e o computador teve de rodá-las para executar o programa, o tempo de execução dependerá do número de instruções em um programa. Um modo de pensar a respeito do tempo de execução é que ele é igual ao número de instruções executadas multiplicado pelo tempo médio por instrução. Portanto, o número de ciclos de clock exigido para um programa pode ser escrito como
O termo ciclos de clock por instrução, que é o número médio de ciclos de clock que cada instrução leva para executar, normalmente é abreviado como CPI. Como diferentes instruções podem exigir diferentes quantidades de tempo, dependendo do que elas fazem, CPI é uma média de todas as instruções executadas no programa. CPI oferece um modo de comparar duas implementações diferentes da mesma arquitetura do conjunto de instruções, pois
o número de instruções executadas para um programa, logicamente, será o mesmo.
ciclos de clock por instruções (CPI) Número médio de ciclos de clock por instrução para um programa ou fragmento de programa.
Usando a equação de desempenho Exemplo Suponha que tenhamos duas implementações da mesma arquitetura de conjunto de instruções. O computador A tem um tempo de ciclo de clock de 250 ps e um CPI de 2,0 para algum programa, e o computador B tem um tempo de ciclo de clock de 500 ps e um CPI de 1,2 para o mesmo programa. Qual computador é mais rápido para este programa e por quanto?
Resposta Sabemos que cada computador executa o mesmo número de instruções para o programa; vamos chamar esse número de I. Primeiro, encontramos o número de ciclos de clock do processador para cada computador:
Agora, podemos calcular o tempo de CPU para cada computador:
De modo semelhante, para B:
Claramente, o computador A é mais rápido. O resultado da diferença de velocidade é dado pela razão dos tempos de execução:
Podemos concluir que o computador A é 1,2 vez mais rápido que o computador B para esse programa.
A equação clássica de desempenho da CPU Agora, podemos escrever essa equação de desempenho básica em termos do contador de instrução (o número de instruções executadas pelo programa), CPI e tempo de ciclo do clock:
ou então, como a taxa de clock é o inverso do tempo de ciclo de clock:
contador de instrução O número de instruções executadas pelo programa. Essas fórmulas são particularmente úteis porque separam os três fatores principais que afetam o desempenho. Podemos usá-las para comparar duas implementações diferentes ou para avaliar uma alternativa de projeto se soubermos seu impacto sobre esses três parâmetros.
Comparando segmentos de código Exemplo Um projetista de compilador está tentando decidir entre duas sequências de código para determinado computador. Os projetistas de hardware forneceram os seguintes fatos: CPI para cada classe de instrução
CPI
A
B
C
1
2
3
Para determinada instrução na linguagem de alto nível, o escritor do compilador está considerando duas sequências de código que exigem as seguintes contagens de instruções: Sequência de código Contagens de instruções para cada classe de instrução A
B
C
1
2
1
2
2
4
1
1
Qual sequência de código executa mais instruções? Qual será mais rápida? Qual é o CPI para cada sequência?
Resposta A sequência 1 executa 2 + 1 + 2 = 5 instruções. A sequência 2 executa 4 + 1 + 1 = 6 instruções. Portanto, a sequência 1 executa menos instruções. Podemos usar a equação para ciclos de clock de CPU com base na contagem de instruções e CPI, a fim de descobrir o número total de ciclos de clock para cada sequência:
Isso gera:
Assim, a sequência de código 2 é mais rápida, embora execute uma instrução extra. Como a sequência de código 2 leva menos ciclos de clock em geral, mas tem mais instruções, ela deverá ter um CPI menor. Os valores de CPI podem ser calculados por
Colocando em perspectiva A Figura 1.15 mostra as medições básicas em diferentes níveis no computador e o que está sendo medido em cada caso. Podemos ver como esses fatores são combinados para gerar um tempo de execução medido em segundos por programa:
FIGURA 1.15 Os componentes básicos do desempenho e como cada um é medido.
Lembre-se sempre de que a única medida completa e confiável do desempenho do computador é o tempo. Por exemplo, mudar o conjunto de instruções para reduzir sua contagem, pode levar a uma organização com tempo de ciclo de clock menor ou CPI maior, que compensa a melhoria na contagem de instruções. De modo semelhante, como o CPI depende do tipo das instruções executadas, o código que executa o menor número de instruções pode não ser o mais rápido. Como determinar o valor desses fatores na equação de desempenho? Podemos medir o tempo de execução da CPU rodando o programa, e o tempo do ciclo de clock normalmente é publicado como parte da documentação de um computador. A contagem de instruções e o CPI podem ser mais difíceis de se obter. Naturalmente, se soubermos a taxa de clock e o tempo de execução da CPU, só precisamos da contagem de instruções ou do CPI para determinar o outro. Podemos medir a contagem de instruções usando ferramentas de software que determinam o perfil da execução ou usando um simulador da arquitetura. Como alternativa, podemos usar contadores de hardware, que estão incluídos na maioria dos processadores, para registrar uma série de medidas, incluindo o número de instruções executadas, o CPI médio e, frequentemente, as origens da perda de desempenho. Como a contagem de instruções depende da arquitetura, mas não da implementação exata, podemos medir a contagem de instruções sem conhecer todos os detalhes da implementação. Porém, o CPI depende de
diversos detalhes de projeto no computador, incluindo o sistema de memória e a estrutura do processador (conforme veremos nos Capítulos 4 e 5), além da mistura de tipos de instruções executados em uma aplicação. Assim, o CPI varia por aplicação, bem como entre implementações com o mesmo conjunto de instruções. O exemplo anterior mostra o perigo de usar apenas um fator (contagem de instruções) para avaliar o desempenho. Ao comparar dois computadores, você precisa examinar todos os três componentes, que se combinam para formar o tempo de execução. Se alguns dos fatores forem idênticos, como a taxa de clock no exemplo anterior, o desempenho pode ser determinado comparando-se todos os fatores não idênticos. Como o CPI varia por mix de instruções, tanto a contagem de instruções quanto o CPI precisam ser comparados, mesmo que as taxas de clock sejam idênticas. Vários exercícios ao final deste capítulo lhe pedem para avaliar uma série de melhorias de computador e compilador, que afetam a taxa de clock, CPI e contagem de instruções. Na Seção 1.10, examinaremos uma medida de desempenho comum, que não incorpora todos os termos e, portanto, pode ser enganosa.
mix de instruções Uma medida da frequência dinâmica das instruções por um ou muitos programas.
Entendendo o desempenho do programa O desempenho de um programa depende do algoritmo, da linguagem, do compilador, da arquitetura e do hardware real. A tabela a seguir resume como esses componentes afetam os fatores na equação de desempenho da CPU. Compo nent e de hard ware ou soft ware
Afeta o quê?
Como?
Algorit mo
Contage O algoritmo determina o número de instruções do programa-fonte executadas e, portanto, o m número de instruções de processador executadas. O algoritmo também pode afetar o CPI, de favorecendo instruções mais lentas ou mais rápidas. Por exemplo, se o algoritmo utiliza mais instr operações de ponto flutuante, ele tenderá a ter um CPI mais alto. uçõe s,
s, poss ivel men te CPI Lingua ge m de pro gra ma ção
Contage A linguagem de programação certamente afeta a contagem de instruções, pois as instruções na m linguagem são traduzidas para instruções de processador, o que determina a contagem de de instruções. A linguagem também pode afetar o CPI por causa dos seus recursos; por exemplo, instr uma linguagem com um suporte intenso para abstração de dados (por exemplo, Java) exigirá uçõe chamadas indiretas, que usarão instruções de CPI mais alto. s, CPI
Compil Contage A eficiência do compilador afeta a contagem de instruções e os ciclos médios por instruções, pois ado m o compilador determina a tradução das instruções da linguagem-fonte para instruções r de do computador. O papel do compilador pode ser muito complexo e afetar o CPI de maneiras instr complexas. uçõe s, CPI Arquite Contage A arquitetura do conjunto de instruções afeta todos os três aspectos do desempenho da CPU, pois tura m afeta as instruções necessárias para uma função, o custo em ciclos de cada instrução e a taxa do de de clock geral do processador. con instr junt uçõe o s, de taxa inst de ruç cloc ões k, CPI
Detalhamento Embora você possa esperar que o CPI mínimo seja 1,0, conforme veremos no Capítulo 4, alguns processadores buscam e executam múltiplas instruções por ciclo de clock. Para refletir essa técnica, alguns projetistas invertem o CPI para falar sobre IPC, ou instruções por ciclo de clock. Se um processador executa, em média, duas instruções por ciclo de clock, então ele tem um IPC de 2 e, portanto, um CPI de 0,5.
Detalhamento Embora o ciclo de clock tenha sido tradicionalmente fixo, para economizar energia ou aumentar temporariamente o desempenho, os processadores atuais podem variar suas taxas de clock, de modo que precisaríamos usar a taxa de clock média para um programa. Por exemplo, o Intel Core i7 aumentará
temporariamente a taxa de clock em cerca de 10% até que o chip aqueça muito. A Intel chama isso de modo Turbo.
Verifique você mesmo Determinada aplicação escrita em Java roda por 15 segundos em um processador de desktop. Um novo compilador Java é lançado, exigindo apenas 60% das instruções do compilador antigo. Infelizmente, isso aumenta o CPI em 1,1. Com que velocidade podemos esperar que a aplicação rode usando esse novo compilador? Escolha a resposta certa a partir das três opções a seguir:
a. b.
c.
1.7. A barreira da potência A Figura 1.16 mostra o aumento na taxa de clock e na potência de oito gerações de microprocessadores Intel durante 30 anos. Tanto a taxa de clock quanto a potência aumentaram rapidamente durante décadas e depois se estabilizaram recentemente. O motivo pelo qual elas cresceram juntas é que estão correlacionadas e o motivo para o seu recuo recente é que chegamos ao limite de potência prático para o resfriamento dos microprocessadores comuns.
FIGURA 1.16 Taxa de clock e potência para microprocessadores Intel x86 durante oito gerações e 25 anos. O Pentium 4 fez um salto drástico na taxa de clock e potência, porém menor em desempenho. Os problemas térmicos do Prescott levaram ao abandono da linha Pentium 4. A linha Core 2 retorna a uma pipeline mais simples, com menores taxas de clock e múltiplos processadores por chip. Os pipelines do Core i5 seguem suas pegadas.
Embora a potência ofereça um limite para o que podemos resfriar, na era pósPC, o recurso realmente crítico é a energia. A vida da bateria pode superar o desempenho no dispositivo móvel pessoal, e os arquitetos de computadores em escala de warehouse tentam reduzir os custos da alimentação e resfriamento de 100.000 servidores, pois os custos são muito altos nessa escala. Assim como a medição do tempo em segundos é uma medida mais segura do desempenho do programa do que uma taxa como MIPS (Seção 1.10), a métrica de energia em
joules é uma medida melhor do que uma potência como watts, que é simplesmente joules/segundo. A tecnologia dominante para circuitos integrados é denominada Complementary Metal Oxide Semiconductor (CMOS). Para CMOS, a principal fonte de dissipação de potência é a chamada potência dinâmica — ou seja, a potência que é consumida quando os transistores mudam do estado 0 para 1 e vice-versa. A energia dinâmica depende da carga capacitiva de cada transistor, da tensão elétrica aplicada:
Esta equação é a energia de um pulso durante a transição lógica de 0 → 1 → 0 ou 1 → 0 → 1. A energia de uma única transição é, então,
A potência exigida por transistor é simplesmente o produto da energia de uma transição e a frequência das transições:
A frequência comutada é uma função da taxa de clock. A carga capacitiva por transistor é uma função do número de transistores conectados a uma saída (chamado de fanout) e da tecnologia, que determina a capacitância dos fios e dos transistores. Com relação à Figura 1.16, como as taxas de clock poderiam crescer por um fator de 1.000 enquanto a potência crescia por um fator apenas de 30? A potência pode ser diminuída reduzindo-se a tensão elétrica, o que ocorreu a cada nova geração da tecnologia, e a potência é uma função da tensão elétrica ao quadrado. Normalmente, a tensão elétrica foi reduzida em 15% por geração. Em 20 anos, as tensões passaram de 5V para 1V, motivo pelo qual o aumento na potência é de apenas 30 vezes.
Potência relativa Exemplo Suponha que tenhamos desenvolvido um novo processador, mais simples, que tem 85% da carga capacitiva do processador mais antigo e mais complexo. Além do mais, considere que ele tenha tensão ajustável, de modo que pode reduzir a tensão em 15% em comparação com o processador B, o que resulta em um encolhimento de 15% na frequência. Qual é o impacto sobre a potência dinâmica?
Resposta
Assim, a razão de potência é
Logo, o novo processador usa cerca de metade da potência do processador antigo. O problema hoje é que reduzir ainda mais a tensão parece causar muito vazamento nos transistores, como torneiras de água que não conseguem ser completamente fechadas. Até mesmo hoje, cerca de 40% do consumo de potência nos chips de servidor é decorrente de vazamentos. Se os transistores começassem a vazar mais, o processo inteiro poderia se tornar incontrolável. Para tentar resolver o problema de potência, os projetistas já conectaram grandes dispositivos a fim de aumentar o resfriamento e depois desligaram partes do chip que não são usadas em determinado ciclo de clock. Embora existam muitas maneiras mais dispendiosas de resfriar os chips e, portanto, aumentar a potência para, digamos, 300 watts, essas técnicas são muito caras para computadores de desktop e até mesmo servidores, sem falar nos dispositivos móveis pessoais.
Como os projetistas de computador bateram contra a barreira da potência, eles precisaram de uma nova maneira de prosseguir e escolheram um caminho diferente do modo como projetavam microprocessadores nos primeiros 30 anos.
Detalhamento Embora a energia dinâmica seja a principal fonte de dissipação de potência na CMOS, a dissipação de potência estática ocorre devido à corrente de vazamento que flui mesmo quando um transistor está desligado. Nos servidores, o vazamento normalmente é responsável por 40% do consumo de potência. Assim, aumentar o número de transistores aumenta a dissipação de potência, mesmo que os transistores estejam sempre desligados. Diversas técnicas de projeto e inovações de tecnologia estão sendo implantadas para controlar o vazamento, mas é difícil reduzir mais a tensão.
Detalhamento A potência é um desafio para os circuitos integrados por dois motivos. Primeiro, ela precisa ser trazida e distribuída em torno do chip; os microprocessadores modernos utilizam centenas de pinos somente para potência e aterramento! De modo semelhante, diversos níveis de interconexão no chip são usados unicamente para distribuição de potência e aterramento às partes do chip. Segundo, a potência é dissipada como calor, e precisa ser removida. Os chips de servidor podem queimar mais de 100 watts, e o resfriamento do chip e do sistema ao seu redor é um custo importante nos Computadores em Escala de Warehouse (Capítulo 6).
1.8. Mudança de mares: Passando de processadores para multiprocessadores Até agora, a maioria dos softwares têm sido como música escrita para um solista; com a geração atual de chips, estamos adquirindo alguma experiência com duetos e quartetos e outros pequenos grupos; mas compor um trabalho para grande orquestra e coro é um tipo de desafio diferente. Brian Hayes, Computing in a Parallel Universe, 2007.
O limite de potência forçou uma mudança drástica no projeto dos microprocessadores. A Figura 1.17 mostra a melhoria no tempo de resposta dos programas para microprocessadores de desktop ao longo dos anos. Desde 2002, a taxa se reduziu de um fator de 1,5 por ano para um fator de menos de 1,2 por ano.
FIGURA 1.17 Crescimento do desempenho do processador desde meados da década de 1980. Este gráfico representa o desempenho relativo ao VAX 11/780
medido pelos benchmarks SPECint (Seção 1.10). Antes de meados da década de 1980, o crescimento do desempenho do processador foi em grande parte controlado pela tecnologia e teve uma média de 25% por ano. O aumento no crescimento para cerca de 52% desde então é atribuído a ideias arquiteturais e organizacionais mais avançadas. A melhoria de desempenho anual mais alta, de 52% desde meados da década de 1980, significou que o desempenho aumentou por um fator de cerca de sete mais alto em 2002 do que teria sido se permanecesse em 25%. Desde 2002, os limites de potência, o paralelismo disponível em nível de instrução e a latência de memória longa reduziram o desempenho do de processadores únicos recentemente, para cerca de 22% por ano.
Em vez de continuar diminuindo o tempo de resposta de um único programa executando em um único processador, em 2006 todas as empresas de desktop e servidor estavam usando microprocessadores com múltiplos processadores por chip, em que o benefício normalmente está mais no throughput do que no tempo de resposta. Para reduzir a confusão entre as palavras processador e microprocessador, as empresas se referem aos processadores como “cores” (ou núcleos), e esses microprocessadores são chamados genericamente de microprocessadores “multicore” (ou múltiplos núcleos). Logo, um microprocessador “quadcore” é um chip que contém quatro processadores, ou quatro núcleos. No passado, os programadores podiam contar com inovações no hardware, na arquitetura e nos compiladores para dobrar o desempenho de seus programas a cada 18 meses sem ter de mudar uma linha de código. Hoje, para os programadores obterem uma melhoria significativa no tempo de resposta, eles precisam reescrever seus programas de modo que tirem proveito de múltiplos processadores. Além do mais, para obter o benefício histórico de rodar mais rapidamente nos microprocessadores mais novos, os programadores terão de continuar a melhorar o desempenho de seu código à medida que dobra o número de núcleos. Para reforçar como os sistemas de software e hardware trabalham lado a lado, usamos uma seção especial, Interface hardware/software, no livro inteiro, com a primeira aparecendo logo a seguir. Essas seções resumem ideias importantes nessa interface crítica.
Interface de hardware/software O paralelismo sempre foi fundamental para o desempenho na computação, mas normalmente esteve oculto. O Capítulo 4 explicará sobre o pipelining, uma técnica elegante que roda programas mais rapidamente sobrepondo a execução de instruções. Este é um exemplo de paralelismo em nível de instrução, em que a natureza paralela do hardware é retirada de modo que o programador e o compilador possam pensar no hardware como executando instruções sequencialmente. Forçar os programadores a estarem cientes do hardware paralelo e reescrever explicitamente seus programas para serem paralelos foi a “terceira trilha” da arquitetura de computadores, pois empresas no passado, que dependiam dessa mudança no comportamento, fracassaram. Do ponto de vista histórico, é surpreendente que a indústria inteira de TI tenha apostado seu futuro nos programadores finalmente passarem com sucesso para a programação explicitamente paralela.
Por que tem sido tão difícil para os programadores escreverem programas explicitamente paralelos? O primeiro motivo é que a programação paralela é, por definição, programação de desempenho, o que aumenta a dificuldade da programação. Não apenas o programa precisa estar correto, solucionar um problema importante e oferecer uma interface útil às pessoas ou outros programas que o chamam, mas ele também precisa ser rápido. Caso contrário, se você não precisasse de desempenho, bastaria escrever um programa sequencial. O segundo motivo é que rapidez, para o hardware paralelo, significa que o programador precisa dividir uma aplicação, de modo que cada processador tenha, aproximadamente, a mesma quantidade de coisas a fazer ao mesmo tempo, e que o overhead do escalonamento e coordenação não afasta os benefícios de desempenho em potencial do paralelismo. Como uma analogia, suponha que a tarefa fosse escrever um artigo de jornal. Oito repórteres trabalhando no mesmo artigo poderiam potencialmente escrever um artigo oito vezes mais rápido. Para conseguir essa velocidade aumentada, seria preciso desmembrar a tarefa de modo que cada repórter tivesse algo para fazer ao mesmo tempo. Assim, temos de escalonar as subtarefas. Se algo saísse errado e apenas um repórter levasse mais tempo do que os sete outros levaram,
então o benefício de ter oito escritores seria diminuído. Assim, temos de balancear a carga por igual para obter o ganho de velocidade desejado. Outro perigo seria se os repórteres tivessem de gastar muito tempo falando uns com os outros para escrever suas seções. Você também se atrasaria se uma parte do artigo, como a conclusão, não pudesse ser escrita até que todas as outras partes fossem concluídas. Assim, deve-se ter o cuidado para reduzir o overhead de comunicação e sincronização. Para essa analogia e para a programação paralela, os desafios incluem escalonamento, balanceamento de carga, tempo para sincronismo e overhead para comunicação entre as partes. Como você poderia imaginar, o desafio é ainda maior com mais repórteres de um artigo de jornal e mais processadores na programação paralela. Para refletir essa mudança de mares no setor, os próximos cinco capítulos desta edição do livro possuem uma seção sobre as implicações da revolução paralela relacionadas a cada capítulo: ▪ Capítulo 2, Seção 2.11: Paralelismo e instruções: sincronização. Normalmente, tarefas paralelas independentes, às vezes, precisam ser coordenadas como, por exemplo, dizer quando elas completaram seu trabalho. Esse capítulo explica as instruções usadas por processadores multicore para sincronizar tarefas.
▪ Capítulo 3, Seção 3.6: Paralelismo e aritmética de computador: Paralelismo
de subword. Talvez a forma mais simples de se criar paralelismo envolva a computação com elementos em paralelo, como ao multiplicar dois vetores. O paralelismo de subword tira proveito dos recursos fornecidos pela Lei de Moore para fornecer unidades aritméticas mais largas, que podem operar sobre muitos, simultaneamente.
▪ Capítulo 4, Seção 4.10: Paralelismo e paralelismo avançado em nível de instrução. Dada a dificuldade da programação explicitamente paralela, um esforço tremendo foi investido na década de 1990, para que o hardware e o compilador revelassem o paralelismo implícito, inicialmente por meio do pipelining. Esse capítulo descreve algumas dessas técnicas agressivas, incluindo a busca e a execução simultâneas de múltiplas instruções e a estimativa dos resultados das decisões, com a execução especulativa das instruções usando a predição.
▪ Capítulo 5, Seção 5.10: Paralelismo e hierarquias de memória: coerência do cache. Um modo de reduzir o custo da comunicação é fazer com que todos os processadores usem o mesmo espaço de endereço, de modo que qualquer processador possa ler ou gravar quaisquer dados. Visto que todos os processadores atuais utilizam caches para manter uma cópia temporária dos dados na memória mais rápida, mais próxima do processador, é fácil imaginar que a programação paralela seria ainda mais difícil se os caches associados a cada processador tivessem valores inconsistentes dos dados compartilhados. Esse capítulo descreve os mecanismos que mantêm coerentes os dados em todos os caches.
▪ Capítulo 5, Seção 5.11: Paralelismo e hierarquias de memória: Redundant Arrays of Inexpensive Disks. Esta seção descreve como o uso de muitos discos em conjunto pode oferecer um throughput muito maior, que foi a inspiração original dos Redundant Arrays of Inexpensive Disks (RAID). A popularidade real do RAID provou ter uma segurança muito maior, incluindo um número modesto de discos redundantes. Esta seção explica as diferenças no desempenho, custo e fidelidade entre os diferentes níveis de RAID.
Além dessas seções, existe um capítulo inteiro sobre processamento paralelo. O Capítulo 6 entra em mais detalhes sobre os desafios da programação paralela; apresenta as duas técnicas contrastantes para a comunicação de endereçamento compartilhado e passagem explícita de mensagens; descreve o modelo restrito de paralelismo que é mais fácil de programar; discute a dificuldade do benchmarking de processadores paralelos; apresenta um novo modelo de desempenho simples para microprocessadores multicore e finalmente descreve e avalia quatro exemplos de microprocessadores multicore usando esse modelo. Como já dissemos, os Capítulos de 3 a 6 utilizam a multiplicação vetorial de matrizes como um exemplo recorrente, para mostrar como cada tipo de paralelismo pode aumentar o desempenho de forma significativa.
1.9. Vida real: Fabricação e benchmarking do Intel Core i7 Eu acreditava que [os computadores] seriam uma ideia universalmente aplicável, assim como os livros. Só não imaginava que se desenvolveriam tão rapidamente, pois não pensei que fôssemos capazes de colocar tantas peças em um chip, quanto finalmente colocamos. O transistor apareceu inesperadamente. Tudo aconteceu muito mais rápido do que esperávamos. J. Presper Eckert coinventor do Eniac, falando em 1991
Cada capítulo possui uma seção intitulada “Vida Real”, que associa os conceitos no livro com um computador que você pode usar em seu dia a dia. Essas seções abordam a tecnologia na qual se baseiam os computadores modernos. Nesta primeira “Vida Real”, veremos como os circuitos integrados são fabricados e como o desempenho e a potência são medidos com o Intel Core i7, como exemplo.
Benchmark de CPU SPEC Um usuário de computador que executa os mesmos programas todos os dias seria o candidato perfeito para avaliar um novo computador. O conjunto de programas executados formaria uma carga de trabalho. Para avaliar dois sistemas, um usuário simplesmente compararia o tempo de execução da carga de trabalho nos dois computadores. A maioria dos usuários, porém, não está nesta situação. Em vez disso, eles precisam contar com outros métodos que medem o desempenho de um computador candidato, esperando que os métodos reflitam como o computador funcionará com a carga de trabalho do usuário. Essa alternativa normalmente é seguida pela avaliação do computador usando um conjunto de benchmarks — programas escolhidos especificamente para medir o desempenho. Os benchmarks formam uma carga de trabalho que o usuário acredita que irá prever o desempenho da carga de trabalho real. Conforme observamos, para criar o caso comum veloz, você primeiro precisa saber exatamente qual caso é comum, portanto, os benchmarks desempenham um
papel crítico na arquitetura de computadores.
carga de trabalho Um conjunto de programas executados em um computador, que é a coleção real das aplicações executadas por um usuário ou construídas a partir de programas reais para aproximar tal mistura. Uma carga de trabalho típica especifica os programas e as frequências relativas.
benchmark Um programa selecionado para uso na comparação do desempenho de computadores.
O System Performance Evaluation Cooperative (SPEC) é um esforço com patrocínio e suporte de uma série de fornecedores de computadores, a fim de criar conjuntos padrão de benchmarks para sistemas de computador modernos. Em 1989, o SPEC criou originalmente um conjunto de benchmark focalizando o desempenho do processador (agora chamado SPEC89), que evoluiu por cinco gerações. A mais recente é SPEC CPU2006, que consiste em um conjunto de 12 benchmarks de inteiros (CINT2006) e 17 benchmarks de ponto flutuante (CFP2006). Os benchmarks de inteiros variam desde parte de um compilador C até um programa de xadrez e uma simulação de computador quântico. Os benchmarks de ponto flutuante incluem códigos de grade estruturados para modelagem de elemento finito, códigos de método de partículas para dinâmica
molecular e códigos de álgebra linear esparsa para dinâmica de fluidos. A Figura 1.18 descreve os benchmarks de inteiros SPEC e seu tempo de execução no Intel Core i7, mostrando os fatores que explicam o tempo de execução: contagem de instruções, CPI e tempo do ciclo de clock. Observe que o CPI varia por um fator de 5.
FIGURA 1.18 Benchmarks SPECINTC2006 executando no Intel Core i7 920 de 2,66 GHz. Conforme explica a equação na seção “A equação clássica de desempenho da CPU”, anteriormente neste capítulo, o tempo de execução é o produto dos três fatores nesta tabela: contagem de instruções em bilhões, clocks por instrução (CPI) e tempo do ciclo de clock em nanossegundos. SPECratio é simplesmente o tempo de referência, que é fornecido pelo SPEC, dividido pelo tempo de execução medido. O único número mencionado como SPECINTC2006 é a média geométrica dos SPECratios.
Para simplificar o marketing dos computadores, o SPEC decidiu informar um único número para resumir todos os 12 benchmarks de inteiros. As medidas do tempo de execução são primeiro normalizadas dividindo-se o tempo de execução em um processador de referência pelo tempo de execução no computador mensurado; essa normalização gera uma medida, chamada SPECratio, que tem a vantagem de usar resultados numéricos maiores para indicar desempenho melhor. Ou seja, o SPECratio é o inverso do tempo de execução. Uma medição de resumo CINT2006 ou CFP2006 é obtida usando-se a média geométrica dos
SPECratios.
Detalhamento Ao comparar dois computadores usando SPECratios, use a média geométrica, de modo que ela informe a mesma resposta relativa, não importando o computador utilizado para normalizar os resultados. Se calculássemos a média dos valores de tempo de execução normalizados com uma média aritmética, os resultados variariam dependendo do computador que escolhêssemos como referência. A fórmula para a média geométrica é
em que Razão do tempo de execuçãoi é o tempo de execução, normalizado ao computador de referência, para o i° programa de um total de n na carga de trabalho, e
Benchmark de potência SPEC Dada a importância cada vez maior do consumo de energia e potência, o SPEC acrescentou um benchmark para medir a potência. Ele informa o consumo de potência dos servidores em diferentes níveis de carga de trabalho, dividido em incrementos de 10%, por um período de tempo. A Figura 1.19 mostra os resultados para um servidor usando processadores Intel Nehalem, semelhantes aos anteriores.
FIGURA 1.19 SPECpower_ssj2008 executando no Intel Xeon X5620 a 2,66 GHz e soquete dual com 16 GB de DRAM DDR2-667 e um disco SSD de 100 GB.
SPECpower começou com o benchmark SPEC para aplicações comerciais em Java (SPECJBB2005), que exercita processadores, caches e memória principal, além da máquina virtual Java, compilador, coletor de lixo e partes do sistema operacional. O desempenho é medido em throughput e as unidades são operações de negócios por segundo. Mais uma vez, para simplificar o marketing dos computadores, o SPEC resume esses números em um único número, chamado “ssj_ops geral por Watt”. A fórmula para essa única métrica de resumo é
em que ssj_opsi é o desempenho em cada incremento de 10% e potênciai é a potência consumida em cada nível de desempenho.
1.10. Falácias e armadilhas A ciência deve começar com os mitos e com a análise crítica dos mitos. Sir Karl Popper, The Philosophy of Science, 1957
A finalidade de uma seção de falácias e armadilhas, que será incluída em cada capítulo, é explicar alguns conceitos errôneos comuns que você pode encontrar. Chamamos esses equívocos de falácias. Quando estivermos discutindo uma falácia, tentaremos fornecer um contraexemplo. Também discutiremos armadilhas ou erros facilmente cometidos. Em geral, as armadilhas são generalizações de princípios verdadeiros em um contexto restrito. O propósito dessas seções é ajudar a evitar esses erros nas máquinas que você pode projetar ou usar. Falácias e armadilhas de custo/desempenho têm confundido muitos arquitetos de computador, incluindo nós. Consequentemente, esta seção não poupa exemplos relevantes. Vamos começar com uma armadilha que engana muitos projetistas e revela um relacionamento importante no projeto de computadores. Armadilha: Esperar que a melhoria de um aspecto de um computador aumente o desempenho geral por uma quantidade proporcional ao tamanho da melhoria. A grande ideia de tornar o caso comum veloz tem um corolário desmoralizante que tem assolado os projetistas de hardware e de software. Ele nos lembra que a oportunidade de melhoria é afetada pelo tempo que o evento consome.
Um problema simples de projeto ilustra isso muito bem. Suponha que um programa execute em 100 segundos em um computador, com operações de multiplicação responsáveis por 80 segundos desse tempo. Quanto terei de melhorar a velocidade da multiplicação se eu quiser que meu programa execute cinco vezes mais rápido? O tempo de execução do programa depois de fazer a melhoria é dado pela seguinte equação simples, conhecida como lei de Amdahl:
lei de Amdahl Uma regra indicando que a melhoria de desempenho possível com determinado aprimoramento é limitada pela quantidade de utilização do recurso aprimorado. Essa é uma versão quantitativa da lei dos retornos decrescentes. Para este problema:
Como queremos que o desempenho seja cinco vezes mais rápido, o novo tempo de execução deverá ser 20 segundos, gerando
Ou seja, não existe quantidade pela qual podemos melhorar a multiplicação para conseguir um aumento quíntuplo no desempenho, se a multiplicação é responsável por apenas 80% da carga de trabalho. A melhoria de desempenho possível com determinado aprimoramento é limitada pela quantidade de utilização do recurso aprimorado. Esse conceito também gera o que chamamos de lei dos retornos decrescentes na vida diária. Podemos usar a lei de Amdahl para estimar os aprimoramentos no desempenho quando sabemos o tempo consumido para alguma função e seu ganho de velocidade em potencial. A lei de Amdahl, junto com a equação de desempenho da CPU, é uma ferramenta prática para avaliar melhorias em potencial. A lei de Amdahl é explorada com mais detalhes nos exercícios. A lei de Amdahl também é usada para se demonstrar limites práticos do número de processadores paralelos. Examinamos esse argumento na seção de Falácias e Armadilhas do Capítulo 6. Falácia: Os computadores com pouca utilização demandam menos potência. A eficiência de potência importa em baixas utilizações, pois as cargas de trabalho do servidor variam. A utilização de servidores no computador em escala de warehouse no Google, por exemplo, está entre 10% e 50% na maior parte do tempo e em 100% em menos de 1% do tempo. Mesmo com cinco anos para aprender a executar bem o benchmark SPECpower, o computador configurado especialmente para isso, com os melhores resultados em 2012, ainda usava 33%
da potência de pico a 10% da carga. Os sistemas em campo que não estão configurados para o benchmark SPECpower certamente são piores. Como as cargas de trabalho dos servidores variam, mas utilizam uma grande fração da potência máxima, Barroso et al. (2007) argumenta que deveríamos reprojetar o hardware para alcançar a “computação proporcional à energia”. Se os servidores futuros usassem, digamos, 10% da potência máxima a 10% de carga de trabalho, poderíamos reduzir a conta de eletricidade dos centros de dados e nos tornarmos bons cidadãos corporativos em uma era de preocupação crescente com as emissões de CO2. Falácia: Projetar para o desempenho e projetar para eficiência de energia são objetivos não relacionados. Como a energia é potência com o passar do tempo, normalmente acontece que as otimizações de hardware ou software que levam menos tempo economizam energia em geral, mesmo que a otimização gaste um pouco mais de energia quando é usada. Um motivo é que todo o restante do computador está consumindo energia enquanto o programa está em execução, mesmo que a parte otimizada use um pouco mais de energia, o tempo reduzido poderá economizar energia do sistema inteiro. Armadilha: Usar um subconjunto da equação de desempenho como uma métrica de desempenho. Já advertimos sobre o perigo de prever o desempenho com base simplesmente na taxa de clock, ou na contagem de instruções ou no CPI. Outro erro comum é usar apenas dois dos três fatores para comparar o desempenho. Embora o uso de dois dos três fatores possa ser válido em um contexto limitado, o conceito facilmente também é mal utilizado. Sem dúvida, quase todas as alternativas propostas para o uso do tempo como métrica de desempenho por fim levaram a afirmações enganosas, resultados distorcidos ou interpretações incorretas. Uma alternativa ao tempo é o MIPS (milhões de instruções por segundo). Para determinado programa, o MIPS é simplesmente
milhões de instruções por segundo (MIPS) Uma medida da velocidade de execução do programa baseada no número de milhões de instruções. MIPS é calculado como a contagem de instruções dividida pelo produto do tempo de execução e 106. Como MIPS é uma taxa de execução de instruções, MIPS especifica o desempenho inversamente ao tempo de execução; computadores mais rápidos possuem uma taxa de MIPS mais alta. A boa notícia sobre MIPS é que ele é fácil de entender e computadores mais rápidos significam um MIPS maior, que corresponde à intuição. Existem três problemas com o uso do MIPS como uma medida para comparar computadores. Primeiro, MIPS especifica a taxa de execução de instruções, mas não leva em conta as capacidades das instruções. Não podemos comparar computadores com diferentes conjuntos de instruções usando MIPS, pois as contagens de instruções certamente serão diferentes. Segundo, MIPS varia entre os programas no mesmo computador; assim, um computador não pode ter uma única avaliação MIPS. Por exemplo, substituindo o tempo de execução, vemos o relacionamento entre MIPS, taxa de clock e CPI:
O CPI variou em 5 para SPEC CPU2006 em um computador Intel Core i7 na Figura 1.18, de modo que o MIPS também varia. Finalmente, e mais importante, se um novo programa executa mais instruções, e uma é mais rápida que a outra, o MIPS pode variar independentemente do desempenho!
Verifique você mesmo
Considere as seguintes medidas de desempenho para um programa: Medida
Computador A Computador B
Número de instruções 10 bilhões
8 bilhões
Taxa de clock
4 GHz
4 GHz
CPI
1,0
1,1
a. Que computador tem a avaliação MIPS mais alta? b. Qual computador é mais rápido?
1.11. Comentários finais Enquanto o Eniac é equipado com 18.000 válvulas e pesa 30 toneladas, os computadores no futuro poderão ter 1.000 válvulas e talvez pesar apenas 1,5 tonelada. Popular Mechanics, março de 1949
Embora seja difícil prever exatamente o nível de custo/desempenho que os computadores terão no futuro, é seguro dizer que serão muito melhores do que são hoje. Para participar desses avanços, os projetistas e programadores de computador precisam entender várias questões. Os projetistas de hardware e de software constroem sistemas computacionais em camadas hierárquicas; cada camada inferior oculta seus detalhes do nível acima. Esse princípio de abstração é fundamental para compreender os sistemas computacionais atuais, mas isso não significa que os projetistas podem se limitar a conhecer uma única abstração. Talvez o exemplo mais importante de abstração seja a interface entre hardware e software de baixo nível, chamada arquitetura do conjunto de instruções. Manter a arquitetura do conjunto de instruções como uma constante permite que muitas implementações dessa arquitetura — provavelmente variando em custo e desempenho — executem software idêntico. No lado negativo, a arquitetura pode impedir a introdução de inovações que exijam a mudança da interface.
Existe um método confiável para determinar e informar o desempenho usando o tempo de execução dos programas reais como métrica. Esse tempo de execução está relacionado a outras medições importantes que podemos fazer pela seguinte equação:
Usaremos essa equação e seus fatores constituintes muitas vezes. Lembre-se, porém, de que individualmente os fatores não determinam o desempenho: somente o produto, que é igual ao tempo de execução, é uma medida confiável do desempenho.
Colocando em perspectiva O tempo de execução é a única medida válida e incontestável do desempenho. Muitas outras métricas foram propostas e desapareceram. Às vezes, essas
métricas possuem falhas desde o início, não refletindo o tempo de execução; outras vezes, uma métrica que é válida em um contexto limitado é estendida e usada além desse contexto ou sem o esclarecimento adicional necessário para torná-la válida. A tecnologia de hardware vital para os processadores modernos é o silício. De igual importância para uma compreensão da tecnologia de circuito integrado é o conhecimento das taxas de mudança tecnológica esperadas, conforme previsto pela Lei de Moore. Enquanto o silício impulsiona o rápido avanço do hardware, novas ideias na organização dos computadores melhoraram seu custo/desempenho. Duas das principais ideias são a exploração do paralelismo no programa, normalmente por meio de processadores múltiplos, e a exploração da localidade dos acessos a uma hierarquia de memória, em geral por meio de caches.
A eficiência no uso de energia substituiu a área do die como o recurso mais crítico do projeto de microprocessadores. Conservar energia enquanto se tenta aumentar o desempenho tem forçado o setor de hardware a passar para microprocessadores multicore, forçando, assim, o setor de software a passar para a programação do hardware em paralelo. Agora é preciso que haja paralelismo
para melhorar o desempenho.
Os projetos de computadores sempre foram medidos pelo custo e desempenho, além de outros fatores importantes, como energia, confiabilidade, custo de proprietário e escalabilidade (ou facilidade de expansão). Embora este capítulo tenha focalizado o custo, o desempenho e a energia, os melhores projetos buscarão o equilíbrio apropriado para determinado mercado entre todos esses fatores.
Mapa para este livro Na base dessas abstrações estão os cinco componentes clássicos de um computador: caminho de dados, controle, memória, entrada e saída (Figura 1.5). Esses cinco componentes também servem de estrutura para os demais capítulos do livro: ▪ Caminho de dados: Capítulos 3, 4 e 6 ▪ Controle: Capítulos 4 e 6 ▪ Memória: Capítulo 5 ▪ Entrada: Capítulos 5 e 6 ▪ Saída: Capítulos 5 e 6 Como dissemos, o Capítulo 4 descreve como os processadores exploram o paralelismo implícito; e o Capítulo 6 descreve os microprocessadores multicore explicitamente paralelos, que estão no núcleo da revolução paralela. O Capítulo 5 descreve como a hierarquia de memória explora a localidade. O Capítulo 2 descreve os conjuntos de instruções — a interface entre os compiladores e a máquina — e destaca o papel dos compiladores e das linguagens de programação ao usar os recursos do conjunto de instruções. O Apêndice A oferece uma referência para o conjunto de instruções do Capítulo 2. O Capítulo 3 descreve como os computadores tratam dos dados aritméticos. O Apêndice B apresenta o projeto lógico.
1.12. Exercícios As avaliações do tempo relativo à solução dos exercícios são mostradas entre colchetes após cada número de exercício. Em média, um exercício avaliado em [10] levará o dobro do tempo de um avaliado em [5]. As seções do texto, que devem ser lidas antes de resolver um exercício, serão indicadas entre sinais de maior e menor; por exemplo, significa que você deve ler a Seção 1.4, “Sob as tampas”, para ajudar a resolver esse exercício. 1.1 [2] Sem considerar os smartphones usados por um bilhão de pessoas, liste e descreva quatro outros tipos de computadores. 1.2 [5] As oito grandes ideias na arquitetura do computador são semelhantes às ideias de outras áreas. Faça a correspondência das oito ideias da arquitetura de computadores, “Projete pensando na Lei de Moore”, “Use a abstração para simplificar o projeto”, “Torne o caso comum veloz”, “Desempenho pelo paralelismo”, “Desempenho pelo pipelining”, “Desempenho pela predição”, “Hierarquia de memórias”, “Estabilidade pela redundância”, com as seguintes ideias de outras áreas: a. Linhas de montagem na fabricação de automóveis b. Cabos de pontes suspensas c. Sistemas de navegação aérea e marinha que incorporam informações de vento d. Elevadores expressos nos prédios e. Central de reserva de biblioteca f. Aumento da área de porta em um transistor CMOS para diminuir seu tempo de comutação g. Acréscimo de catapultas eletromagnéticas de aeronaves (que são alimentadas eletricamente, ao contrário dos atuais modelos alimentados por vapor), possibilitadas pela maior geração de potência oferecida pela nova tecnologia de reator h. Montagem de carros com piloto automático, cujos sistemas de controle contam parcialmente com sistemas sensores existentes, já instalados no veículo base, como sistemas de afastamento da pista e sistemas inteligentes de controle de navegação 1.3 [2] Descreva as etapas que transformam um programa escrito em linguagem de alto nível, como C, em uma representação que é executada diretamente por um processador de computador.
1.4 [2] Imagine uma tela colorida usando 8 bits para cada uma das cores primárias (vermelho, verde, azul) por pixel e com uma resolução de 1280 × 1024 pixels. a. Qual deve ser o tamanho (em bytes) do buffer de frame a fim de armazenar um frame? b. Quanto tempo levará, no mínimo, para que o frame seja enviado por uma rede de 100 Mbits/s? 1.5 [4] Considere três processadores diferentes, P1, P2 e P3, executando o mesmo conjunto de instruções. P1 tem uma taxa de clock de 3 GHz e um CPI de 1,5. P2 tem uma taxa de clock de 2,5 GHz e um CPI de 1,0. P3 tem uma taxa de clock de 4,0 GHz e um CPI de 2,2. a. Qual processador possui o desempenho mais rápido expressado pelas instruções por segundo? b. Se cada processador executa um programa em 10 segundos, encontre o número de ciclos e o número de instruções. c. Ao tentar reduzir o tempo de execução em 30%, a CPI aumenta em 20%. Qual a taxa de clock que deve ser utilizada para a redução de tempo? 1.6 [20] Considere duas implementações diferentes da mesma arquitetura do conjunto de instruções. Existem quatro classes de instruções, de acordo com seu CPI (classes A, B, C e D). P1 com uma taxa de clock de 2,5 GHz e CPIs de 1, 2, 3 e 3, e P2 com uma taxa de clock de 3 GHz e CPIs de 2, 2, 2 e 2. Dado um programa com uma contagem de instruções dinâmicas de 1,06E6 divididas em classes das seguintes formas: 10% classe A, 20% classe B, 50% classe C e 20% classe D, que implementação é mais rápida? a. Qual é o CPI global para cada implementação? b. Encontre os ciclos de clock exigidos nos dois casos. 1.7 [15] Os compiladores podem ter um impacto profundo sobre o desempenho de uma aplicação. Suponha que, para um programa, o compilador A resulte em uma contagem de instruções dinâmicas de 1,0E9 e tenha um tempo de execução de 1,1 s, enquanto o compilador B resulte em uma contagem de instruções dinâmicas de 1,2E9 e um tempo de execução de 1,5 s. a. Ache o CPI médio para cada programa dado que o processador possui um tempo de ciclo de clock de 1 ns. b. Suponha que os programas compilados sejam executados em dois processadores diferentes. Se os tempos de execução nos dois
processadores são iguais, o quanto mais rápido é o clock do processador que executa o código do compilador A em relação ao clock do processador que executa o código do compilador B? c. Um novo compilador é desenvolvido para usar apenas 6,0E8 instruções e possui um CPI médio de 1,1. Qual é o ganho de velocidade com o uso deste novo compilador, em vez de usar o compilador A ou B no processador original? 1.8 O processador Pentium 4 Prescott, lançado em 2004, tinha uma taxa de clock de 3,6 GHz e tensão de 1,25 V. Suponha que, na média, ele consumisse 10 W de potência estática e 90 W de potência dinâmica. O Core i5 Ivy Bridge, lançado em 2012, tinha uma taxa de clock de 3,4 GHz e tensão de 0,9 V. Suponha que, na média, ele consumisse 30 W de potência estática e 40 W de potência dinâmica. 1.8.1 [5] Para cada processador, ache as cargas capacitivas médias. 1.8.2 [5] Ache a porcentagem da potência dissipada total composta pela potência estática e a razão entre a potência estática e a potência dinâmica de cada tecnologia. 1.8.3 [15] Se a potência total dissipada for reduzida em 10%, quanto a tensão deve ser reduzida para que a corrente de vazamento continue igual? Nota: a potência é definida como o produto entre tensão e corrente. 1.9 Suponha que, para instruções aritméticas, load/store e desvio, um processador tenha CPIs de 1, 12 e 5, respectivamente. Suponha também que, em um único processador, um programa exija a execução de 2,56E9 instruções aritméticas, 1,28E9 instruções load/store, e 256 milhões de instruções de desvio. Suponha que cada processador tenha uma frequência de clock de 2 GHz. Suponha que, quando o programa tem execução paralela por vários núcleos, o número de instruções aritméticas e de load/store por processador seja dividido por 0,7 x p (onde p é o número de processadores), mas o número de instruções de desvio por processador permaneça igual. 1.9.1 [5] Ache o tempo de execução total para esse programa em 1, 2, 4 e 8 processadores, e mostre o ganho de velocidade relativo do resultado com 2, 4 e 8 processadores, em relação ao resultado com único processador. 1.9.2 [10] >§§1.6, 1.8> Se o CPI das instruções aritméticas fosse dobrado, qual seria o impacto sobre o tempo de execução do programa em 1, 2, 4 ou 8 processadores? 1.9.3 [10] Para quanto o CPI das instruções de load/store deveria
ser reduzido, de modo que um único processador corresponda ao desempenho de quatro processadores usando os valores de CPI originais? 1.10 Suponha que um wafer com 15 cm de diâmetro tenha um custo de 12, contenha 84 dies e tenha 0,020 defeitos/cm2. Suponha que um wafer com 20 cm de diâmetro tenha um custo de 15, contenha 100 dies e tenha 0,031 defeitos/cm2. 1.10.1 [10] Ache o aproveitamento para os dois wafers. 1.10.2 [5] Ache o custo por die para os dois wafers. 1.10.3 [5] Se o número de dies por wafer for aumentado em 10% e os defeitos por unidade de área aumentarem em 15%, ache a área do die e o aproveitamento. 1.10.4 [5] Suponha que um processo de fabricação melhore o aproveitamento de 0,92 para 0,95. Ache os defeitos por unidade de área para cada versão da tecnologia dada uma área de die de 200 mm2. 1.11 Os resultados do benchmark bzip2 do SPEC CPU2006 executando em um AMD Barcelona tem uma contagem de instruções de 2,389E2, um tempo de execução de 750 s e um tempo de referência de 9650 s. 1.11.1 [5] Ache o CPI se o tempo de ciclo de clock for 0,333 ns. 1.11.2 [5] Ache o SPECratio. 1.11.3 [5] Ache o aumento no tempo de CPU se o número de instruções do benchmark for aumentado em 10% sem afetar o CPI. 1.11.4 [5] Ache o aumento no tempo de CPU se o número de instruções do benchmark for aumentado em 10% e o CPI for aumentado em 5%. 1.11.5 [5] Ache a mudança no SPECratio para essa mudança. 1.11.6 [10] Suponha que estejamos desenvolvendo uma nova versão do processador AMD Barcelona com uma taxa de clock de 4 GHz. Acrescentamos algumas instruções adicionais ao conjunto de instruções, de modo que o número de instruções foi reduzido em 15%. O tempo de execução é reduzido para 700 s e o novo SPECratio é 13,7. Ache o novo CPI. 1.11.7 [10] Esse valor de CPI é maior do que o obtido em 1.11.1, pois a taxa de clock foi aumentada de 3 GHz para 4 GHz. Determine se o aumento no CPI é semelhante ao da taxa de clock. Se forem diferentes, explique o motivo. 1.11.8 [5] Em quanto o tempo de CPU foi reduzido? 1.11.9 [10] Para um segundo benchmark, libquantum, considere um
tempo de execução de 960 ns, CPI de 1,61 e taxa de clock de 3 GHz. Se o tempo de execução for reduzido por outros 10% sem afetar o CPI e com uma taxa de clock de 4 GHz, determine o número de instruções. 1.11.10 [10] Determine a taxa de clock exigida para dar outra redução de 10% no tempo de CPU enquanto o número de instruções é mantido, e com o CPI inalterado. 1.11.11 [10] Determine a taxa de clock se o CPI for reduzido em 15% e o tempo de CPU em 20%, enquanto o número de instruções permanece inalterado. 1.12 A Seção 1.10 cita como armadilha a utilização de um subconjunto da equação de desempenho como uma métrica de desempenho. Para ilustrar isso, considere os dois processadores a seguir. P1 tem uma taxa de clock de 4 GHz, CPI médio de 0,9 e requer a execução de 5,0E9 instruções. P2 tem uma taxa de clock de 3 GHz, um CPI médio de 0,75 e requer a execução de 1,0E9 instruções. 1.12.1 [5] Uma falácia comum é considerar o computador com a maior taxa de clock como tendo o maior desempenho. Verifique se isso é verdade para P1 e P2. 1.12.2 [10] Outra falácia é considerar que o processador executando o maior número de instruções precisará de um tempo de CPU maior. Considerando que o processador P1 está executando uma sequência de 1,0E9 instruções e que o CPI dos processadores P1 e P2 não muda, determine o número de instruções que P2 pode executar ao mesmo tempo em que P1 precisa para executar 1,0E9 instruções. 1.12.3 [10] Uma falácia comum é usar milhões de instruções por segundo (MIPS) para comparar o desempenho de dois processadores diferentes e considerar que o processador com o maior valor de MIPS tem o maior desempenho. Verifique se isso é verdade para P1 e P2. 1.12.4 [10] Outro valor de desempenho comum é milhões de operações de ponto flutuante por segundo (MFLOPS), definido como: MFLOPS = N° de operações de PF / (Tempo de execução × 1E6) mas esse valor tem os mesmos problemas do MIPS. Considere que 40% das instruções executadas em P1 e P2 sejam instruções de ponto flutuante. Determine os valores de MFLOPS para os programas. 1.13 Outra armadilha citada na Seção 1.10 é esperar aprimorar o desempenho geral de um computador melhorando apenas um aspecto do computador. Considere um computador rodando um programa que requer 250 s, com 70 s
gastos executando instruções de ponto flutuante, 85 s executando instruções de L/S, e 40 s gastos executando instruções de desvio. 1.13.1 [5] Em quanto será reduzido o tempo total se o tempo para as operações de PF for reduzido em 20%? 1.13.2 [5] Em quanto o tempo para operações INT será reduzido se o tempo total for reduzido em 20%? 1.13.3 [5] O tempo total pode ser reduzido em 20% reduzindo-se apenas o tempo para as instruções de desvio? 1.14 Suponha que um programa exija a execução de 50 × 106 instruções de PF, 110 × 106 instruções INT, 80 × 106 instruções de L/S e 16 × 106 instruções de desvio. O CPI para cada tipo de instrução é 1, 1, 4 e 2, respectivamente. Suponha que o processador tenha uma taxa de clock de 2 GHz. 1.14.1 [10] Em quanto devemos melhorar o CPI para instruções de PF se quisermos que o programa seja executado duas vezes mais rápido? 1.14.2 [10] Em quanto devemos melhorar o CPI para instruções de L/S se quisermos que o programa seja executado duas vezes mais rápido? 1.14.3 [5] Em quanto o tempo de execução do programa deve ser melhorado se o CPI das instruções INT e PF for reduzido em 40% e o CPI de L/S e desvio for reduzido em 30%? 1.15 [5] Quando um programa é adaptado para ser executado em vários processadores em um sistema multiprocessador, o tempo de execução em cada processador é composto de tempo de execução e o tempo de overhead exigido para as seções críticas bloqueadas e/ou para enviar dados de um processador para outro. Suponha que um programa exija t = 100 s de tempo de execução em um processador. Quando p processadores estiverem em execução, cada processador requer t/p s, além de mais 4 s de overhead, independentemente do número de processadores. Calcule o tempo de execução por processador para 2, 4, 8, 16, 32, 64 e 128 processadores. Para cada caso, liste o ganho de velocidade correspondente em relação a um único processador e a razão entre o ganho de velocidade real e o ganho de velocidade ideal (ganho de velocidade se não houvesse overhead).
Respostas das Seções “Verifique você mesmo” §1.1, página 8: Questões para discussão: muitas respostas são aceitáveis. §1.4, página 20: Memória DRAM: volátil, tempo de acesso curto de 50 a 70 nanossegundos, e custo por GB é US$ 5 a US$ 10. Memória em disco: não volátil, tempos de acesso são de 100.000 a 400.000 vezes mais lento que a
volátil, tempos de acesso são de 100.000 a 400.000 vezes mais lento que a DRAM, e custo por GB é 100 vezes mais barato que a DRAM. Memória flash: não volátil, tempos de acesso são de 100 a 1000 vezes mais lentos que a DRAM, e custo por GB é 7 a 10 vezes mais barato que a DRAM. §1.5, página 24: 1, 3 e 4 são motivos válidos. A resposta 5 geralmente pode ser verdadeira, pois o alto volume pode tornar o investimento extra para reduzir o tamanho do die em, digamos, 10%, uma boa decisão econômica, mas isso não precisa ser verdadeiro. §1.6, página 28: 1. a: ambos, b: latência, c: nem um nem outro. 7 segundos. §1.6, página 33: b. §1.10, página 43: a. Computador A tem a maior avaliação MIPS. b. Computador B é mais rápido.
Instruções: A Linguagem dos Computadores Eu falo espanhol com Deus, italiano com as mulheres, francês com os homens e alemão com meu cavalo. Charles V, imperador romano (1500-1558)
2.1 Introdução 2.2 Operações do hardware do computador 2.3 Operandos do hardware do computador 2.4 Números com sinal e sem sinal 2.5 Representando instruções no computador 2.6 Operações lógicas 2.7 Instruções para tomada de decisões 2.8 Suporte a procedimentos no hardware do computador 2.9 Comunicando-se com as pessoas 2.10 Endereçamento no MIPS para operandos imediatos e endereços de 32 bits 2.11 Paralelismo e instruções: Sincronização 2.12 Traduzindo e iniciando um programa 2.13 Um exemplo de ordenação em C para juntar tudo isso 2.14 Arrays versus ponteiros 2.15 Vida real: instruções ARMv7 (32 bits)
2.16 Vida real: Instruções x86 2.17 Vida real: instruções ARMv8 (64 bits) 2.18 Falácias e armadilhas 2.19 Comentários finais 2.20 Exercícios
Os cinco componentes clássicos de um computador
2.1. Introdução Para controlar o hardware de um computador é preciso falar sua linguagem. As palavras da linguagem de um computador são chamadas instruções e seu vocabulário é denominado conjunto de instruções. Neste capítulo você verá o conjunto de instruções de um computador real, tanto na forma escrita pelos humanos quanto na forma lida pelo computador. Apresentamos as instruções em
um padrão top-down. Começando com uma notação parecida com uma linguagem de programação restrita; nós a refinamos passo a passo, até que você veja a linguagem real de um computador. O Capítulo 3 continua nosso caminho, expondo o hardware para a aritmética e a representação dos números de ponto flutuante.
conjunto de instruções O vocabulário dos comandos entendidos por uma determinada arquitetura. Você poderia pensar que as linguagens dos computadores fossem tão diversificadas quanto as dos humanos, mas, na realidade, as linguagens de computação são muito semelhantes, mais parecidas com dialetos regionais do que linguagens independentes. Logo, quando você aprender uma, será fácil entender as outras. O conjunto de instruções escolhido vem da MIPS Technologies, e é um exemplo elegante dos conjuntos de instruções projetados desde a década de 1980. Para demonstrar como é fácil selecionar outros conjuntos de instruções, daremos uma olhada rápida em três outros conjuntos de instruções populares. 1. ARMv7 é semelhante ao MIPS. Mais de 9 bilhões de chips com processadores ARM foram fabricados em 2011, tornando-o o conjunto de instruções mais popular no mundo. 2. O segundo exemplo é o Intel x86, que controla tanto o PC quanto a nuvem da era pós-PC. 3. O terceiro exemplo é o ARMv8, que amplia o tamanho de endereçamento do ARMv7 de 32 bits para 64 bits. Ironicamente, conforme veremos, esse conjunto de instruções de 2013 está mais próximo do MIPS do que do ARMv7. A semelhança dos conjuntos de instruções ocorre porque todos os computadores são construídos a partir de tecnologias de hardware baseadas em princípios básicos semelhantes e porque existem algumas operações básicas que todos os computadores precisam oferecer. Além do mais, os projetistas de computador possuem um objetivo comum: encontrar uma linguagem que facilite o projeto do hardware e do compilador enquanto maximiza o desempenho e minimiza o custo. Este objetivo é antigo; a citação a seguir foi escrita antes que você pudesse comprar um computador e é tão verdadeira hoje quanto era em 1947:
É fácil ver, por métodos lógicos formais, que existem certos [conjuntos de instruções] que são adequados para controlar e causar a execução de qualquer sequência de operações... As considerações realmente decisivas, do ponto de vista atual, na seleção de um [conjunto de instruções], são mais de natureza prática: a simplicidade do equipamento exigido pelo [conjunto de instruções] e a clareza de sua aplicação para os problemas realmente importantes, junto com a velocidade com que tratam esses problemas. Burks, Goldstine e von Neumann, 1947
A “simplicidade do equipamento” é uma consideração tão valiosa para os computadores de hoje, quanto foi para os da década de 1950. O objetivo deste capítulo é ensinar um conjunto de instruções que siga esse conselho, mostrando como ele é representado no hardware e o relacionamento entre as linguagens de programação de alto nível e essa linguagem mais primitiva. Nossos exemplos estão na linguagem de programação C. Aprendendo como representar as instruções, você também descobrirá o segredo da computação: o conceito de programa armazenado. Você também verá o impacto das linguagens de programação e das otimizações do compilador sobre o desempenho. Concluímos com uma visão da evolução histórica dos conjuntos de instruções e uma visão geral dos outros dialetos do computador.
conceito de programa armazenado A ideia de que as instruções e os dados de muitos tipos podem ser armazenados na memória como números, levando ao computador do programa armazenado. Revelamos o conjunto de instruções do MIPS aos poucos, mostrando o raciocínio em conjunto com as estruturas do computador. Esse tutorial passo a passo entrelaça os componentes com suas explicações, tornando a linguagem de máquina mais fácil de digerir. A Figura 2.1 oferece uma prévia do conjunto de instruções abordado neste capítulo.
FIGURA 2.1 Assembly do MIPS revelado neste capítulo. Esta informação também pode ser encontrada na Coluna 1 do Guia de Referência do MIPS no final deste livro.
2.2. Operações do hardware do computador Certamente é preciso haver instruções para realizar as operações aritméticas fundamentais. Burks, Goldstine e von Neumann, 1947
Todo computador precisa ser capaz de realizar aritmética. A notação em assembly do MIPS
instrui um computador a somar as duas variáveis b e c para colocar sua soma em a. Essa notação é rígida no sentido de que cada instrução aritmética do MIPS realiza apenas uma operação e sempre precisa ter exatamente três variáveis. Por exemplo, suponha que queiramos colocar a soma das variáveis b, c, d e e na variável a. (Nesta seção, estamos sendo deliberadamente vagos com relação ao que é uma “variável”; na próxima seção, vamos explicar com detalhes.) Esta sequência de instruções soma as quatro variáveis:
Portanto, são necessárias três instruções para somar quatro variáveis. As palavras à direita do símbolo (#) em cada linha acima são comentários para o leitor humano, e os computadores os ignoram. Note que, diferentemente de outras linguagens de programação, cada linha desta linguagem pode conter, no máximo, uma instrução. Outra diferença para a linguagem C é que comentários sempre terminam no final da linha. O número natural de operandos para uma operação como a adição é três: os dois números sendo somados e um local para colocar a soma. Exigir que cada instrução tenha exatamente três operações, nem mais nem menos, está de acordo com a filosofia de manter o hardware simples: o hardware para um número variável de operandos é mais complicado do que o hardware para um número fixo. Essa situação ilustra o primeiro dos quatro princípios básicos de projeto do hardware: Princípio de Projeto 1: Simplicidade favorece a regularidade. Agora podemos mostrar, nos dois exemplos a seguir, o relacionamento dos programas escritos nas linguagens de programação de mais alto nível com os programas nessa notação mais primitiva.
Compilando duas instruções de atribuição em C no MIPS Exemplo Este segmento de um programa em C contém as cinco variáveis a, b, c, d e e. Como o Java evoluiu a partir da linguagem C, este exemplo e os próximos funcionam para qualquer uma dessas linguagens de programação de alto nível:
A tradução de C para as instruções em linguagem assembly do MIPS é realizada pelo compilador. Mostre o código do MIPS produzido por um compilador.
Resposta Uma instrução MIPS opera sobre dois operandos de origem e coloca o resultado em um operando de destino. Logo, as duas instruções simples anteriores são compiladas diretamente nessas duas instruções em assembly do MIPS:
Compilando uma atribuição em C complexa no MIPS Exemplo Uma instrução um tanto complexa contém as cinco variáveis f, g, h, i e j:
O que um compilador C poderia produzir?
Resposta O compilador precisa desmembrar essa instrução em várias instruções assembly, pois somente uma operação é realizada por instrução MIPS. A primeira instrução MIPS calcula a soma de g e h. Temos de colocar o resultado em algum lugar, de modo que o compilador crie uma variável temporária, chamada t0:
Embora a próxima operação seja subtrair, precisamos calcular a soma de i e j antes de podermos subtrair. Assim, a segunda instrução coloca a soma de i e j em outra variável temporária criada pelo compilador, chamada t1:
Finalmente, a instrução de subtração subtrai a segunda soma da primeira e coloca a diferença na variável f, completando o código compilado:
Verifique você mesmo Para determinada função, que linguagem de programação provavelmente utiliza mais linhas de código? Coloque as três representações a seguir em ordem. 1. Java 2. C 3. Assembly do MIPS
Detalhamento Para aumentar a portabilidade, o Java foi idealizado originalmente como um interpretador de software. O conjunto de instruções desse interpretador é chamado bytecode Java, que é muito diferente do conjunto de instruções do MIPS. Para chegar a um desempenho próximo ao programa em C equivalente, os sistemas Java de hoje normalmente compilam os bytecodes Java para os conjuntos de instruções nativos, como MIPS. Como essa compilação em geral é feita muito mais tarde do que para programas em C, esses compiladores Java normalmente são denominados compiladores JIT (Just In Time — no momento exato). A Seção 2.12 mostra como os JITs são usados mais tarde que os compiladores C no processo de inicialização e a Seção 2.13 mostra as consequências no desempenho de compilar versus interpretar programas Java.
2.3. Operandos do hardware do computador Ao contrário dos programas em linguagens de alto nível, os operandos das instruções aritméticas são restritos; precisam ser de um grupo limitado de locais especiais, embutidos diretamente no hardware, chamados registradores. Os registradores são primitivas usadas no projeto do hardware que também são visíveis ao programador quando o projeto do computador é concluído, portanto, você pode pensar nos registradores como os “tijolos” na construção do computador. O tamanho de um registrador na arquitetura MIPS é de 32 bits; os grupos de 32 bits ocorrem com tanta frequência que recebem o nome de word (palavra) na arquitetura MIPS.
Word (palavra) A unidade de acesso natural de um computador, normalmente um grupo de 32 bits; corresponde ao tamanho de um registrador na arquitetura MIPS. Uma diferença importante entre as variáveis de uma linguagem de programação e os registradores é o número limitado de registradores, normalmente 32 nos computadores atuais, como o MIPS. Assim, continuando em nossa evolução passo a passo da representação simbólica da linguagem MIPS, nesta seção, incluímos a restrição de que cada um dos três operandos das instruções aritméticas do MIPS precisa ser escolhido a partir de um dos 32 registradores de 32 bits.
A razão para o limite dos 32 registradores pode ser encontrada no segundo dos três princípios de projeto básicos da tecnologia de hardware: Princípio de Projeto 2: Menor significa mais rápido. Uma quantidade muito grande de registradores pode aumentar o tempo do ciclo do clock simplesmente porque os sinais eletrônicos levam mais tempo quando precisam atravessar uma distância maior. Orientações como “menor significa mais rápido” não são absolutas; 31 registradores podem não ser mais rápidos do que 32. Mesmo assim, a verdade por trás dessas observações faz com que os projetistas de computador as levem a sério. Nesse caso, o projetista precisa equilibrar o desejo dos programas por mais registradores, com o desejo do projetista de manter o ciclo de clock rápido. Outro motivo para não usar mais de 32 é o número de bits que seria necessário no formato da instrução, como demonstra a Seção 2.5. O Capítulo 4 mostra o papel central que os registradores desempenham na construção do hardware; como veremos neste capítulo, o uso eficaz dos registradores é fundamental para o desempenho do programa. Embora pudéssemos simplesmente escrever instruções usando números para os registradores, de 0 a 31, a convenção do MIPS é usar nomes com um sinal de cifrão seguido por dois caracteres para representar um registrador. A Seção 2.8 explicará os motivos por trás desses nomes. Por enquanto, usaremos $s0, $s1... para os registradores que correspondem às variáveis dos programas em C e Java, e $t0, $t1... para os registradores temporários necessários para compilar o programa nas instruções MIPS.
Compilando uma atribuição em C usando registradores Exemplo É tarefa do compilador associar variáveis do programa aos registradores. Considere, por exemplo, a instrução de atribuição do nosso exemplo anterior:
As variáveis f, g, h, i e j são associadas aos registradores $s0, $s1, $s2, $s3 e $s4, respectivamente. Qual é o código MIPS compilado?
Resposta O programa compilado é muito semelhante ao exemplo anterior, exceto que substituímos as variáveis pelos nomes dos registradores mencionados anteriormente, mais dois registradores temporários, $t0 e $t1, que correspondem às variáveis temporárias de antes:
Operandos em memória As linguagens de programação possuem variáveis simples, que contêm elementos de dados isolados, como nesses exemplos, mas também possuem estruturas de dados mais complexas — arrays (ou sequências) e estruturas. Essas estruturas de dados complexas podem conter muito mais elementos de dados do que a quantidade de registradores em um computador. Logo, como um computador pode representar e acessar estruturas tão grandes? Lembre-se dos cinco componentes de um computador, apresentados no Capítulo 1 e desenhados no início deste capítulo. O processador só pode manter uma pequena quantidade de dados nos registradores, mas a memória do computador contém milhões de elementos de dados. Logo, as estruturas de dados (arrays e estruturas) são mantidas na memória. Conforme explicamos, as operações aritméticas só ocorrem com registradores nas instruções MIPS; assim, o MIPS precisa incluir instruções que transferem dados entre a memória e os registradores. Essas instruções são denominadas instruções de transferência de dados. Para acessar uma palavra na memória, a instrução precisa fornecer o endereço de memória. A memória é apenas uma sequência grande e unidimensional, com o endereço atuando como índice para esse array, começando de 0. Por exemplo, na Figura 2.2, o endereço do terceiro elemento de dados é 2 e o valor de Memória[2] é 10.
FIGURA 2.2 Endereços de memória e conteúdo da memória nesses locais. Se esses elementos fossem palavras, esses endereços estariam incorretos, pois o MIPS, na realidade, usa endereços de bytes, com cada palavra representando quatro bytes. A Figura 2.3 mostra o endereçamento para palavras sequenciais na memória.
instrução de transferência de dados Um comando que move dados entre a memória e os registradores.
endereço Um valor usado para delinear o local de um elemento de dados específicos dentro de uma sequência da memória. A instrução de transferência de dados que copia dados da memória para um
registrador tradicionalmente é chamada de load. O formato da instrução load é o nome da operação seguido pelo registrador a ser carregado, depois uma constante e o registrador usado para acessar a memória. A soma da parte constante da instrução com o conteúdo do segundo registrador forma o endereço da memória. O nome MIPS real para essa instrução é lw, significando load word (carregar palavra).
Compilando uma atribuição quando um operando está na memória Exemplo Vamos supor que A seja uma sequência de 100 palavras e que o compilador tenha associado as variáveis g e h aos registradores $s1 e $s2, como antes. Vamos supor também que o endereço inicial da sequência, ou endereço base, esteja em $s3. Compile esta instrução de atribuição em C:
Resposta Embora haja uma única operação nessa instrução de atribuição, um dos operandos está na memória, de modo que primeiro precisamos transferir A[8] para um registrador. O endereço desse elemento da sequência é a soma da base da sequência A, encontrada no registrador $s3, com o número para selecionar o elemento 8. Os dados devem ser colocados em um registrador temporário, para uso na próxima instrução. Com base na Figura 2.2, a primeira instrução compilada é
(A seguir, faremos um pequeno ajuste nessa instrução, mas usaremos essa versão simplificada, por enquanto.) A seguinte instrução pode operar sobre o valor em $t0 (que é igual a A[8]), já que está em um registrador. A instrução precisa somar h (contido em $s2) com A[8] ($t0) e colocar a soma no
registrador correspondente a g (associado a $s1):
A constante na instrução de transferência de dados (8) é chamada de offset e o registrador acrescentado para formar o endereço ($s3) é chamado de registrador base.
Interface hardware/software Além de associar variáveis a registradores, o compilador aloca estruturas de dados, como arrays e estruturas, em locais na memória. O compilador pode, então, colocar o endereço inicial apropriado nas instruções de transferência de dados. Como os bytes de 8 bits são úteis em muitos programas, a maioria das arquiteturas atuais endereça bytes individuais. Portanto, o endereço de uma palavra combina os endereços dos 4 bytes dentro da palavra. Logo, os endereços sequenciais das palavras diferem em quatro vezes. Por exemplo, a Figura 2.3 mostra os endereços MIPS reais para a Figura 2.2; o endereço em bytes da terceira palavra é 8.
FIGURA 2.3 Endereços reais de memória do MIPS e conteúdo da memória para essas palavras. A mudança de endereços está destacada para comparar com a Figura 2.2. Como o MIPS endereça cada byte, endereços de palavras são múltiplos de 4: existem 4 bytes em uma palavra.
No MIPS, palavras precisam começar em endereços que sejam múltiplos de 4. Esse requisito é denominado restrição de alinhamento e muitas arquiteturas o exigem. (O Capítulo 4 explica por que o alinhamento ocasiona transferências de dados mais rápidas.)
restrição de alinhamento Um requisito de que os dados estejam alinhados na memória em limites naturais. Os computadores se dividem entre aqueles que utilizam o endereço do byte mais à esquerda, ou big end, como endereço da palavra e os que utilizam o
byte mais à direita, ou little end. O MIPS está no campo do big endian. Visto que a ordem só importa se você acessar os dados idênticos tanto como uma palavra quanto como quatro bytes, poucos precisam estar cientes da ordenação dos bytes. (O Apêndice A mostra as duas opções para numerar os bytes de uma palavra.) O endereçamento em bytes também afeta o índice do array. Para obter o endereço em bytes apropriado no código anterior, o offset a ser somado ao registrador base $s3 precisa ser 4 × 8, ou 32, de modo que o endereço de load selecione A[8], e não A[8/4]. (Veja a armadilha relacionada na Seção 2.18.) A instrução complementar ao load tradicionalmente é chamada de store; ela copia dados de um registrador para a memória. O formato de um store é semelhante ao de um load: o nome da operação, seguido pelo registrador a ser armazenado, depois o offset para selecionar o elemento do array e finalmente o registrador base. Mais uma vez, o endereço MIPS é especificado, em parte, por uma constante e, em parte, pelo conteúdo de um registrador. O nome real no MIPS é SW, significando store word (armazenar palavra).
Interface hardware/software Como os endereços nos loads e stores são números binários, podemos ver por que a DRAM para a memória vem em tamanhos binários, e não em tamanhos decimais. Ou seja, em gebibytes (230) ou tebibytes (240), e não em gigabytes (109) ou terabytes (1012) (Figura 1.1).
Compilando com load e store Exemplo Suponha que a variável h esteja associada ao registrador $s2 e o endereço base do array A esteja em $s3. Qual é o código assembly do MIPS para a instrução de atribuição em C a seguir?
Resposta
Resposta Embora haja uma única operação na instrução em C, agora dois dos operandos estão na memória, de modo que precisamos de ainda mais instruções MIPS. As duas primeiras instruções são iguais às do exemplo anterior, exceto que, desta vez, usamos o offset apropriado para o endereçamento do byte na instrução load word, a fim de selecionar A[8], e a instrução add coloca a soma em $t0:
A instrução final armazena a soma em A[12], usando 48 (4 × 12) como offset e o registrador $s3 como registrador base.
Load word e store word são as instruções que copiam words entre memória e registradores na arquitetura MIPS. Outras marcas de computadores utilizam outras instruções juntamente com load e store para transferir dados. Uma arquitetura com essas alternativas é a Intel x86, descrita na Seção 2.16.
Interface hardware/software Muitos programas possuem mais variáveis do que os computadores possuem registradores. Consequentemente, o compilador tenta manter as variáveis usadas com mais frequência nos registradores e coloca o restante na memória, usando loads e stores para mover variáveis entre os registradores e a memória. O processo de colocar as variáveis menos utilizadas (ou aquelas necessárias mais adiante) na memória é chamado de spilled registers (ou registradores derramados). O princípio de hardware relacionando tamanho e velocidade sugere que a memória deve ser mais lenta que os registradores, pois existem menos registradores. Isso realmente acontece; os acessos aos dados são mais rápidos se os dados estiverem nos registradores, ao invés de estarem na memória.
Além do mais, os dados são mais úteis quando em um registrador. Uma instrução aritmética MIPS pode ler dois registradores, operar sobre eles e escrever o resultado. Uma instrução de transferência de dados MIPS só lê um operando ou escreve um operando, sem operar sobre ele. Assim, os registradores MIPS levam menos tempo para serem acessados e possuem maior vazão do que a memória, tornando os dados nos registradores mais rápidos de acessar e mais simples de usar. O acesso aos registradores também usa menos energia do que o acesso à memória. Para conseguir o melhor desempenho e economizar energia, uma arquitetura de conjunto de instruções precisa ter um número suficiente de registradores, e os compiladores precisam usar os registradores de modo eficaz.
Constantes ou operandos imediatos Muitas vezes, um programa usará uma constante em uma operação — por exemplo, ao incrementar um índice a fim de apontar para o próximo elemento de um array. Na verdade, mais da metade das instruções aritméticas do MIPS possuem uma constante como operando quando executam os benchmarks SPEC2006. Usando apenas as instruções vistas até aqui, teríamos de ler uma constante da memória para utilizá-la. (As constantes teriam de ser colocadas na memória quando o programa fosse carregado.) Por exemplo, para somar a constante 4 ao registrador $s3, poderíamos usar o código
supondo que $s1 + AddrConstant4 seja o endereço de memória da constante 4. Uma alternativa que evita a instrução load é oferecer versões das instruções aritméticas em que o operando seja uma constante. Essa instrução add rápida, com uma constante no lugar do operando, é chamada add imediato ou addi. Para somar 4 ao registrador $s3, simplesmente escrevemos
Os operandos constantes ocorrem com frequência e, incluindo constantes dentro das instruções aritméticas, as operações são muito mais rápidas e usam menos energia do que se as constantes fossem lidas da memória. A constante zero tem outro emprego, que é simplificar o conjunto de instruções por oferecer variações úteis. Por exemplo, a operação move é apenas uma instrução de soma na qual cada operando é zero. Portanto, o MIPS dedica o registrador $zero para ter sempre o valor zero. (Como você deve esperar, ele é o registrador número 0.) O uso da frequência para justificar as inclusões de constantes é outro exemplo da grande ideia de tornar o caso comum veloz.
Verifique você mesmo Dada a importância dos registradores, qual é a taxa de aumento no número de registradores em um chip com o passar do tempo? 1. Muito rápida: eles aumentam tão rapidamente quanto a Lei de Moore, que prevê a duplicação do número de transistores em um chip a cada 18 meses. 2. Muito lenta: como os programas normalmente são distribuídos em linguagem de máquina, existe uma inércia na arquitetura do conjunto de instruções, e, por isso, o número de registradores aumenta apenas quando novos conjuntos de instruções se tornam viáveis.
Detalhamento
Detalhamento Embora os registradores MIPS neste livro tenham 32 bits de largura, existe uma versão de 64 bits do conjunto de instruções MIPS, definido com 32 registradores de 64 bits. Para distingui-los, eles são chamados oficialmente de MIPS-32 e MIPS-64. Neste capítulo, usamos um subconjunto do MIPS-32. As Seções 2.16 e 2.18 mostram a diferença muito maior entre o ARMv7 com endereços de 32 bits e o seu sucessor de 64 bits, o ARMv8.
Detalhamento O endereçamento formado pelo registrador-base mais o offset do MIPS é uma combinação excelente para as estruturas e os arrays, pois o registrador pode apontar para o início da estrutura, e o offset pode selecionar o elemento desejado. Veremos esse exemplo na Seção 2.13.
Detalhamento O registrador nas instruções de transferência de dados foi criado originalmente para manter o índice do array com o offset utilizado para o endereço inicial do array. Assim, o registrador-base também é chamado registrador índice. As memórias de hoje são muito maiores e o modelo de software para alocação de dados é mais sofisticado, de modo que o endereçobase do array normalmente é passado em um registrador, pois não caberá no offset, conforme veremos.
Detalhamento Como o MIPS admite constantes negativas, a subtração imediata não é necessária no MIPS.
2.4. Números com sinal e sem sinal Primeiro, vamos revisar rapidamente como um computador representa números. Os humanos são ensinados a pensar na base 10, mas os números podem ser representados em qualquer base. Por exemplo, 123 base 10 = 1111011 base 2. Os números são mantidos no hardware do computador como uma série de sinais eletrônicos altos e baixos, e por isso são considerados números de base 2.
(Assim como os números de base 10 são chamados números decimais, os números de base 2 são chamados números binários.) Um único dígito de um número binário, portanto, é o “átomo” da computação, pois toda a informação é composta de dígitos binários ou bits. Esse bloco de montagem fundamental pode assumir dois valores, que podem ser imaginados como várias alternativas: alto ou baixo, ligado ou desligado, verdadeiro ou falso ou 1 ou 0.
dígito binário Também chamado bit. Um dos dois números na base 2 (0 ou 1), que são os componentes básicos da informação. Generalizando, em qualquer base numérica, o valor do i-ésimo dígito d é
em que i começa com 0 e aumenta da direita para a esquerda. Isso leva a um modo óbvio de numerar os bits na word: basta usar a potência da base para esse bit. Subscritamos os números decimais com dec e os números binários com bin. Por exemplo,
Representa
Logo, os bits são numerados com 0, 1, 2, 3, ... da direita para a esquerda em uma palavra. O desenho a seguir mostra a numeração dos bits dentro de uma
word MIPS e o posicionamento do número 1011bin:
Como as palavras são desenhadas vertical e horizontalmente, esquerda e direita podem não ser termos muito claros. Logo, o termo bit menos significativo é usado para se referir ao bit mais à direita (bit 0, no exemplo anterior) e bit mais significativo para o bit mais à esquerda (bit 31).
bit menos significativo O bit mais à direita em uma palavra MIPS.
bit mais significativo O bit mais à esquerda em uma palavra MIPS. Cada palavra no MIPS possui 32 bits de largura, de modo que podemos representar 232 padrões diferentes de 32 bits. É natural deixar que essas representações mostrem os números de 0 a 232 – 1 (4.294.967.295dec):
Ou seja, os números binários de 32 bits podem ser representados em termos do valor do bit vezes uma potência de 2 (aqui, xi significa o i-ésimo bit de x):
Por motivos que veremos em breve, esses números positivos são denominados números sem sinal.
Interface hardware/software A base 2 não é natural para os seres humanos; temos 10 dedos e, portanto, a base 10 é natural. Por que os computadores não usaram decimal? Na verdade, o primeiro computador comercial oferecia aritmética decimal. O problema foi que o computador ainda usava sinais do tipo ligado e desligado, de modo que um dígito decimal era simplesmente representado por vários dígitos binários. O sistema decimal mostrou ser tão ineficaz que os computadores subsequentes passaram a usar apenas binário, convertendo para a base 10 apenas para eventos de entrada/saída relativamente pouco frequentes. Lembre-se de que os padrões de bits binários que acabamos de mostrar simplesmente representam os números. Os números, na realidade, possuem uma quantidade infinita de dígitos, com quase todos sendo 0, exceto por alguns dos dígitos mais à direita. Só que, normalmente, não mostramos os 0s à esquerda. O hardware pode ser projetado para somar, subtrair, multiplicar e dividir esses padrões de bits. Se o número que é o resultado correto de tais operações não puder ser representado por esses bits de hardware mais à direita, diz-se que houve um overflow (ou estouro). Fica a critério da linguagem de programação, do sistema operacional e do programa determinar o que fazer quando isso ocorre. Os programas de computador calculam números positivos e negativos, de modo que precisamos de uma representação que faça a distinção entre o positivo e o negativo. A solução mais óbvia é acrescentar um sinal separado, que convenientemente possa ser representado em um único bit; o nome dessa representação é sinal e magnitude. Infelizmente, a representação com sinal e magnitude possui várias desvantagens. Primeiro, não é óbvio onde colocar o bit de sinal. À direita? À esquerda? Os primeiros computadores tentaram ambos. Segundo, os somadores de sinal e magnitude podem precisar de uma etapa extra para definir o sinal, pois não podemos saber, com antecedência, qual será o sinal correto. Finalmente, um bit de sinal separado significa que a representação com sinal e magnitude possui
um zero positivo e um zero negativo, o que pode ocasionar problemas para os programadores desatentos. Como resultado desses problemas, a representação com sinal e magnitude logo foi abandonada. Em busca de uma alternativa mais atraente, levantou-se a questão com relação a qual seria o resultado, para números sem sinal, se tentássemos subtrair um número grande de um número pequeno. A resposta é que ele tentaria pegar emprestado de uma sequência de 0s à esquerda, de modo que o resultado seria uma sequência de 1s à esquerda. Como não havia uma alternativa melhor óbvia, a solução final foi escolher a representação que tornasse o hardware simples: 0s iniciais significa positivo e 1s iniciais significa negativo. Essa convenção para representar os números binários com sinal é chamada representação por complemento de dois:
A metade positiva dos números, de 0 a 2.147.483.647dec (231 – 1), utiliza a mesma representação de antes. O padrão de bits seguinte (1000 ... 0000bin) representa o número mais negativo –2.147.483.648dec (–231). Ele é seguido por um conjunto decrescente de números negativos: –2.147.483.647dec (1000 ... 0001bin) até –1dec (1111 ... 1111bin). A representação em complemento de dois possui um número negativo, – 2.147.483.648dec, que não possui um número positivo correspondente. Este desequilíbrio era uma preocupação para o programador desatento, mas a
representação com sinal e magnitude gerava problemas para o programador e para o projetista do hardware. Consequentemente, todo computador, hoje em dia, utiliza a representação de números binários por complemento de dois para os números com sinal. A representação por complemento de dois tem a vantagem de que todos os números negativos possuem 1 no bit mais significativo. Consequentemente, o hardware só precisa testar esse bit para ver se um número é positivo ou negativo (com 0 considerado positivo). Esse bit normalmente é denominado bit de sinal. Reconhecendo o papel do bit de sinal, podemos representar números positivos e negativos de 32 bits em termos do valor do bit vezes uma potência de 2:
O bit de sinal é multiplicado por –231 e o restante dos bits é multiplicado pelas versões positivas de seus respectivos valores de base.
Conversão de binário para decimal Exemplo Qual é o valor decimal deste número em complemento de dois com 32 bits?
Resposta Substituindo os valores dos bits do número na fórmula anterior:
Logo, veremos um atalho para simplificar a conversão de negativo para positivo. Assim como uma operação com números sem sinal pode ocasionar overflow na capacidade do hardware de representar o resultado, uma operação com números em complemento de dois também pode. O overflow ocorre quando o bit mais à esquerda da representação binária do hardware não é igual ao número infinito de dígitos à esquerda (o bit de sinal está incorreto): 0 à esquerda do padrão de bits quando o número é negativo ou 1 quando o número é positivo.
Interface hardware/software Com sinal e sem sinal se aplica a loads e também à aritmética. A função de um load com sinal é copiar o sinal repetidamente para preencher o restante do registrador — chamado extensão do sinal —, mas sua finalidade é colocar uma representação correta do número dentro desse registrador. Os loads sem sinal simplesmente preenchem com 0s à esquerda dos dados, pois o número representado pelo padrão de bits é sem sinal. Ao carregar uma word de 32 bits em um registrador de 32 bits, o ponto é discutível; loads com sinal e sem sinal são idênticos. O MIPS oferece dois tipos de loads de byte: load byte (lb) trata o byte como um número com sinal e, assim, estende por sinal para preencher os 24 bits mais à esquerda do registrador, enquanto load byte unsigned (lbu) trabalha com inteiros sem sinal. Como os programas em C quase sempre usam bytes para representar caracteres, em vez de considerar bytes como inteiros com sinal muito curtos, lbu é usada de modo praticamente exclusivo para os loads de byte.
Interface hardware/software Diferente dos números discutidos anteriormente, os endereços de memória naturalmente começam com 0 e continuam até o maior endereço. Em outras palavras, endereços negativos não fazem sentido. Assim, os programas desejam lidar às vezes com números que podem ser positivos ou negativos e às vezes com números que só podem ser positivos. Algumas linguagens de programação refletem essa distinção. A linguagem C, por exemplo, chama os primeiros de integers ou inteiros (declarados como int no programa), e os últimos de unsigned integers ou inteiros sem sinal (unsighned int). Alguns
guias de estilo C recomendam ainda declarar os primeiros como sighned int, para deixar a distinção clara. Vamos examinar dois atalhos úteis quando trabalhamos com os números em complemento de dois. O primeiro atalho é um modo rápido de negar um número binário no complemento de dois. Basta inverter cada 0 para 1 e cada 1 para 0, depois somar um ao resultado. Este atalho é baseado na observação de que a soma de um número e sua representação invertida precisa ser 111 ... 111bin, que representa –1. Como x + = –1, portanto, x + + 1 = 0 ou + 1 = –x. (Usamos a notação para significar inverter cada bit em x de 0 para 1 e viceversa.)
Atalho para negação Exemplo Negue 2dec e depois verifique o resultado negando –2dec.
Resposta
Negando esse número, invertendo os bits e somando um,
Na outra direção,
primeiro é invertido e depois incrementado:
O próximo atalho nos diz como converter um número binário representado em n bits para um número representado com mais de n bits. Por exemplo, o campo imediato nas instruções load, store, branch, add e set on less than contém um número de 16 bits em complemento de dois, representando de –32.768dec (–215) a 32.767dec (215 – 1). Para somar o campo imediato a um registrador de 32 bits, o computador precisa converter esse número de 16 bits para o seu equivalente em 32 bits. O atalho é pegar o bit mais significativo da menor quantidade — o bit de sinal — e replicá-lo para preencher os novos bits na quantidade maior. Os bits antigos são simplesmente copiados para a parte da direita da nova word. Esse atalho normalmente é chamado de extensão de sinal.
Atalho para extensão de sinal Exemplo Converta as versões binárias de 16 bits de 2dec e –2dec para números binários de 32 bits.
Resposta A versão binária de 16 bits do número 2 é
Ele é convertido para um número de 32 bits criando-se 16 cópias do valor
do bit mais significativo (0) e colocando-as na metade esquerda da word. A metade direita recebe o valor antigo:
Vamos negar a versão de 16 bits de 2 usando o atalho anterior. Assim,
torna-se
Criar uma versão de 32 bits do número negativo significa copiar o bit de sinal 16 vezes e colocá-lo à esquerda:
Esse truque funciona porque os números positivos em complemento de dois realmente possuem uma quantidade infinita de 0s à esquerda e os que são negativos em complemento de dois possuem uma quantidade infinita de 1s. O padrão binário que representa um número esconde os bits iniciais para caber na largura do hardware; a extensão do sinal simplesmente restaura alguns deles.
Resumo O ponto principal desta seção é que precisamos representar inteiros positivos e negativos dentro de uma palavra do computador e, embora existam prós e contras a qualquer opção, a escolha predominante desde 1965 tem sido o complemento de dois.
Detalhamento Para números decimais sem sinal, usamos “—” para representar negativo, pois não existem limites para o tamanho de um número decimal. Dado um tamanho de word fixo, strings com bits binários e hexadecimais (Figura 2.4) podem codificar o sinal; logo, normalmente não usamos “ + ” ou “–” com notação binária ou hexadecimal.
FIGURA 2.4 A tabela de conversão hexadecimal-binário. Basta substituir um dígito hexadecimal pelos quatro dígitos binários correspondentes e vice-versa. Se o tamanho do número binário não for um múltiplo de 4, prossiga da direita para a esquerda.
Verifique você mesmo Qual é o valor decimal deste número de 64 bits em complemento de dois?
Detalhamento O complemento de dois recebe esse nome em decorrência da regra de que a soma sem sinal de um número de n bits e seu negativo de n bits é 2n; logo, o
complemento ou a negação de um número em complemento de dois x é 2n – x. Uma terceira representação alternativa para o complemento de dois, de sinal e magnitude é chamada complemento de um. O negativo de um complemento de um é encontrado invertendo-se cada bit, de 0 para 1 e de 1 para 0 ou x–. Essa relação ajuda a explicar seu nome, pois o complemento de x é 2n – x – 1. Essa também foi uma tentativa de ser uma solução melhor do que a técnica de sinal e magnitude, e vários computadores científicos utilizaram a notação. Essa representação é semelhante ao complemento de dois, exceto que também possui dois 0s: 00...00bin é o 0 positivo, e 11...11bin é o 0 negativo. O maior número negativo 10...000bin representa –2.147.483.647dec e, por isso, os positivos e negativos são balanceados. Os que aderiram ao complemento de um precisaram de uma etapa extra para subtrair um número e, por isso, o complemento de dois domina hoje.
complemento de um Uma notação que representa o valor mais negativo por 10 ... 000bin e o valor mais positivo por 01 ... 11bin, deixando um número igual de negativos e positivos, mas terminando com dois zeros, um positivo (00 ... 00bin) e um negativo (11 ... 11bin). O termo também é usado para significar a inversão de cada bit em um padrão: 0 para 1 e 1 para 0. Uma notação final, que veremos quando tratarmos de ponto flutuante no Capítulo 3, é representar o valor mais negativo por 00...000bin e o valor mais positivo por 11...11bin, com 0 normalmente tendo o valor 10...00bin. Isso é chamado de notação deslocada (biased notation), pois desloca o número de modo que o número mais o deslocamento tenha uma representação não negativa.
notação deslocada Uma notação que representa o valor mais negativo por 00 ... 000bin e o valor mais positivo por 11 ... 11bin, com 0 normalmente tendo o valor 10 ... 00bin, deslocando assim o número, de modo que o número mais o deslocamento têm uma representação não negativa.
2.5. Representando instruções no computador Agora, estamos prontos para explicar a diferença entre o modo como os humanos instruem os computadores e como os computadores veem as instruções. As instruções são mantidas no computador como uma série de sinais eletrônicos altos e baixos e podem ser representadas como números. Na verdade, cada parte da instrução pode ser considerada um número individual e a colocação desses números lado a lado forma a instrução. Como os registradores são referenciados por quase todas as instruções, é preciso haver uma convenção para mapear nomes de registrador em números. Na linguagem assembly do MIPS, os registradores $s0 a $s7 são mapeados nos registradores de 16 a 23 e os registradores $t0 a $t7 são mapeados nos registradores de 8 a 15. Logo, $s0 significa o registrador 16, $s1 significa o registrador 17, $s2 significa o registrador 18, ..., $t0 significa o registrador 8, $t1 significa o registrador 9, e assim por diante. Nas próximas seções, descreveremos a convenção para o restante dos 32 registradores.
Traduzindo uma instrução assembly MIPS para uma instrução de máquina Exemplo Realizaremos a próxima etapa no refinamento da linguagem do MIPS como um exemplo. Mostraremos a versão da linguagem real do MIPS para a instrução representada simbolicamente por
primeiro como uma combinação dos números decimais e depois dos números binários.
Resposta A representação decimal é:
Cada um desses segmentos de uma instrução é chamado de campo. O primeiro e o último campos (contendo 0 e 32, nesse caso) combinados dizem ao computador MIPS que essa instrução realiza soma. O segundo campo indica o número do registrador que é o primeiro operando de origem da operação de soma (17 = $s1) e o terceiro campo indica o outro operando fonte para a soma (18 = $s2). O quarto campo contém o número do registrador que deverá receber a soma (8 = $t0). O quinto campo não é utilizado nessa instrução, de modo que é definido como 0. Assim, a instrução soma o registrador $s1 ao registrador $s2 e coloca a soma no registrador $t0. Essa instrução também pode ser representada com campos em números binários, em vez de decimal:
Esse layout da instrução é chamado formato de instrução. Como você pode ver pela contagem do número de bits, essa instrução MIPS ocupa exatamente 32 bits — o mesmo tamanho da palavra de dados. Acompanhando nosso princípio de projeto, de que a simplicidade favorece a regularidade, todas as instruções MIPS possuem 32 bits de extensão.
formato de instrução Uma forma de representação de uma instrução, composta de campos de números binários. Para distinguir do assembly, chamamos a versão numérica das instruções de linguagem de máquina, e a sequência dessas instruções é o código de máquina.
linguagem de máquina Representação binária utilizada para a comunicação dentro de um sistema computacional.
Pode parecer que agora você estará lendo e escrevendo sequências longas e cansativas de números binários. Evitamos esse tédio usando uma base maior do que a binária, que pode ser convertida com facilidade para binária. Como quase todos os tamanhos de dados no computador são múltiplos de 4, os números hexadecimais (base 16) são muito comuns. Como a base 16 é uma potência de 2, podemos converter facilmente substituindo cada grupo de quatro dígitos binários por um único dígito hexadecimal e vice-versa. A Figura 2.4 converte hexadecimal para binário e vice-versa.
hexadecimal Números na base 16. Visto que frequentemente lidamos com bases numéricas diferentes, para evitar confusão, vamos anexar em subscrito dec aos números decimais, bin aos números binários e hex aos números hexadecimais. (Se não houver um subscrito, a base padrão é 10.) A propósito, C e Java utilizam a notação 0xnnnn para os números hexadecimais.
Binário para hexadecimal e vice-versa Exemplo Converta os seguintes números hexadecimais e binários para a outra base:
Resposta Usando a Figura 2.4, temos a solução ao olhar na tabela em uma direção:
E depois na outra direção:
Campos do MIPS Os campos do MIPS recebem nomes para facilitar seu tratamento:
Aqui está o significado de cada nome nos campos das instruções MIPS: ▪ op: operação básica da instrução, tradicionalmente chamado de opcode. ▪ rs: o primeiro registrador do operando fonte. ▪ rt: o segundo registrador do operando fonte. ▪ rd: o registrador do operando de destino. Ele recebe o resultado da operação. ▪ shamt: “Shift amount” (quantidade de deslocamento). (A Seção 2.6 explica as instruções de shift e esse termo; ele não será usado até lá, e, por isso, o campo contém zero nesta seção.) ▪ funct: função. Esse campo, normalmente chamado código de função,
seleciona a variante específica da operação no campo op.
opcode O campo que denota a operação e formato de uma instrução. Existe um problema quando uma instrução precisa de campos maiores do que aqueles mostrados. Por exemplo, a instrução load word precisa especificar dois registradores e uma constante. Se o endereço tivesse de usar um dos campos de 5 bits no formato anterior, a constante dentro da instrução load word seria limitada a apenas 25 ou 32. Esta constante é utilizada para selecionar elementos dos arrays ou estruturas de dados e normalmente precisa ser muito maior do que 32. Esse campo de 5 bits é muito pequeno para realizar algo útil. Logo, temos um conflito entre o desejo de manter todas as instruções com o mesmo tamanho e o desejo de ter um formato de instrução único. Isso nos leva ao último princípio de projeto de hardware: Princípio de Projeto 3: Um bom projeto exige bons compromissos. O compromisso escolhido pelos projetistas do MIPS é manter todas as instruções com o mesmo tamanho, exigindo, assim, diferentes tipos de formatos para diferentes tipos de instruções. Por exemplo, o formato anterior é chamado de tipo-R (de registrador) ou formato R. Um segundo tipo de formato de instrução é chamado tipo I (de imediato) ou formato I, e é utilizado pelas instruções imediatas e de transferência de dados. Os campos do formato I são:
O endereço de 16 bits significa que uma instrução load word pode carregar qualquer palavra dentro de uma região de ±215 ou 32.768 bytes (±213 ou 8.192 words) do endereço no registrador base rs. De modo semelhante, a soma imediata é limitada a constantes que não sejam maiores do que ±215. Vemos que o uso de mais de 32 registradores seria difícil nesse formato, pois os campos rs e rt precisariam cada um de outro bit, tornando mais difícil encaixar tudo em uma palavra. Vejamos a instrução load word apresentada anteriormente:
Aqui, 19 (para $s3) é colocado no campo rs, 8 (para $t0) é colocado no campo rt, e 32 é colocado no campo de endereço. Observe que o significado do campo rt mudou para essa instrução: em uma instrução load word, o campo rt especifica o registrador de destino, que recebe o resultado do load. Embora o uso de vários formatos complique o hardware, podemos reduzir a complexidade mantendo os formatos semelhantes. Por exemplo, os três primeiros campos nos formatos de tipo R e tipo I possuem o mesmo tamanho e têm os mesmos nomes; o tamanho do quarto campo no tipo I é igual à soma dos tamanhos dos três últimos campos do tipo R. Caso você esteja curioso, os formatos são diferenciados pelos valores no primeiro campo: cada formato recebe um conjunto distinto de valores no primeiro campo (op), de modo que o hardware sabe se deve tratar a última metade da instrução como três campos (tipo R) ou como um único campo (tipo I). A Figura 2.5 mostra os números utilizados em cada campo para as instruções MIPS descritas aqui.
FIGURA 2.5 Codificação de instruções MIPS. Na tabela, “reg” significa um número de registrador entre 0 e 31, “endereço” significa um endereço de 16 bits, e “n.a.” (não se aplica) significa que esse campo não aparece nesse formato. Observe que as instruções add e sub têm o mesmo valor no campo op; o hardware usa o campo funct para decidir sobre a variante da operação: somar (32) ou subtrair (34).
Traduzindo do assembly MIPS para a linguagem de máquina Exemplo
Agora, já podemos usar um exemplo completo, daquilo que o programador escreve até o que o computador executa. Se $t1 possui a base do array A e $s2 corresponde a h, então a instrução de atribuição
é compilada para
Qual o código em linguagem de máquina MIPS para essas três instruções?
Resposta Por conveniência, primeiro vamos representar as instruções em linguagem de máquina usando os números decimais. Pela Figura 2.5, podemos determinar as três instruções em linguagem de máquina: op
rs
rt
35
9
8
0
18
8
43
9
8
rd
endereço/shamt funct 1200
8
0
32
1200
A instrução lw é identificada por 35 (Figura 2.5) no primeiro campo (op). O registrador base 9 ($t1) é especificado no segundo campo (rs), e o registrador de destino 8 ($t0) é especificado no terceiro campo (rt). O offset para selecionar A[300] (1200 = 300 × 4) aparece no campo final (endereço). A instrução add, que vem em seguida, é especificada com 0 no primeiro campo (op) e 32 no último campo (funct). Os três operandos de registrador (18, 8 e 8) aparecem no segundo, no terceiro e no quarto campos e correspondem a $s2, $t0 e $t0. A instrução sw é identificada com 43 no primeiro campo. O restante dessa última instrução é idêntico à instrução lw. Como 1200dec = 0000 0100 1011 0000bin, o equivalente binário ao formato
decimal é o seguinte:
Observe a semelhança das representações binárias da primeira e última instruções. A única diferença está no terceiro bit a partir da esquerda, que está destacado.
Interface hardware/software O desejo de manter todas as instruções com o mesmo tamanho está em conflito com o desejo de ter o máximo de registradores possível. Qualquer aumento no número de registradores usa, pelo menos, um bit a mais em cada campo de registrador do formato da instrução. Dadas essas restrições e o princípio de projeto de que menor é mais rápido, a maior parte dos conjuntos de instruções hoje possui 16 ou 32 registradores de uso geral. A Figura 2.6 resume as partes do assembly do MIPS descritas nesta seção. Como veremos no Capítulo 4, a semelhança das representações binárias de instruções relacionadas simplifica o projeto do hardware. Essas instruções são outro exemplo da regularidade da arquitetura MIPS.
FIGURA 2.6 Arquitetura MIPS revelada até a Seção 2.5. Os dois formatos de instrução MIPS até aqui são R e I. Os 16 primeiros bits são iguais: ambos contêm um campo op, indicando a operação básica; um campo rs, indicando um dos operandos origem; e um campo rt, que especifica o outro operando origem,
exceto para load word, em que especifica o registrador destino. O formato R divide os 16 últimos bits em um campo rd, especificando o registrador destino; um campo shamt, explicado na Seção 2.6; e o campo funct, que particulariza a operação específica das instruções no formato R. O formato I mantém os 16 bits finais como um único campo de endereço.
Linguagem de máquina do MIPS Colocando em perspectiva Os computadores de hoje são baseados em dois princípios fundamentais: 1. As instruções são representadas como números. 2. Os programas são armazenados na memória para serem lidos ou escritos, assim como os números. Esses princípios levam ao conceito de programa armazenado; sua invenção permite que o “gênio da computação saia de sua garrafa”. A Figura 2.7 mostra o poder do conceito; especificamente, a memória pode conter o código-fonte de um editor de textos, o código de máquina compilado correspondente, o texto que o programa compilado está usando e até mesmo o compilador que gerou o código de máquina.
FIGURA 2.7 O conceito de programa armazenado. Os programas armazenados permitem que um computador que realiza contabilidade se torne, em um piscar de olhos, um computador que ajuda um autor a escrever um livro. A troca acontece simplesmente carregando a memória com programas e dados e depois dizendo ao computador para iniciar a execução em determinado local na memória. Tratar as instruções da mesma maneira que os dados, simplifica
bastante tanto o hardware da memória quanto o software dos sistemas computacionais. Especificamente, a tecnologia de memória necessária para os dados também pode ser usada para programas, e programas como compiladores, por exemplo, podem traduzir o código escrito em uma notação muito mais conveniente para os humanos, em código que o computador consiga entender.
Uma consequência de instruções em forma de números é que os programas normalmente são entregues como arquivos de números binários. A implicação comercial é que os computadores podem herdar softwares já prontos, desde que sejam compatíveis com um conjunto de instruções existente. Essa “compatibilidade binária” normalmente alinha o setor em torno de uma quantidade muito pequena de arquiteturas de conjuntos de instruções.
Verifique você mesmo Que instrução MIPS isto representa? Escolha entre uma das quatro opções a seguir. op
rs
rt
rd
0
8
9
10
shamt funct 0
34
2.6. Operações lógicas “Ao contrário”, continuou Tweedledee, “se foi assim, poderia ser; e se assim fosse, seria; mas como não é, então não é. Isso é lógico.” Lewis Carroll, Alice no país das maravilhas, 1865
Embora os primeiros computadores se concentrassem em palavras completas, logo ficou claro que era útil atuar sobre campos de bits dentro de uma palavra ou até mesmo sobre bits individuais. Examinar os caracteres dentro de uma palavra, cada um dos quais armazenados como 8 bits, é um exemplo dessa operação (Seção 2.9). Instruções foram acrescentadas às linguagens de programação e às arquiteturas de conjunto de instruções para simplificar, entre outras coisas, o empacotamento e o desempacotamento dos bits em words. Essas instruções são chamadas operações lógicas. A Figura 2.8 mostra as operações lógicas em C, Java e MIPS.
FIGURA 2.8 Operadores lógicos em C e Java e suas instruções MIPS correspondentes. MIPS implementa NOT usando um NOR com um operando sendo zero.
A primeira classe dessas operações é chamada de shifts (deslocamentos). Elas movem todos os bits de uma word para a esquerda ou direita, preenchendo os bits que ficaram vazios com 0s. Por exemplo, se o registrador $s0 tivesse
e fosse executada a instrução para deslocar 4 bits à esquerda, o novo valor se pareceria com:
O dual de um shift à esquerda é um shift à direita. Os nomes reais das duas instruções shift no MIPS são shift left logical (sll) e shift right logical (srl). A instrução a seguir realiza essa operação, supondo que o valor original estava no
registrador $t0 e o resultado deverá ir para o registrador $t2:
Adiamos até agora a explicação do campo shamt, do formato R. O nome significa shift amount (quantidade de deslocamento) e é usado nas instruções de deslocamento. Logo, a versão em linguagem de máquina da instrução anterior é
A codificação de sll é 0 nos campos op e funct, rd contém 10 (registrador $t2), rt contém $s0 e shamt contém 4. O campo rs não é utilizado e, por isso, é definido como 0. O deslocamento lógico à esquerda oferece um benefício adicional. O deslocamento à esquerda de i bits gera o mesmo resultado que multiplicar por 2i, assim como o deslocamento de um número decimal por i dígitos é equivalente a multiplicar por 10i. Por exemplo, a instrução sll anterior desloca de 4, o que gera o mesmo resultado que multiplicar por 24 ou 16. O primeiro padrão de bits descrito anteriormente representa 9, e 9 × 16 = 144, o valor do segundo padrão de bits. Outra operação útil que isola os campos é AND, uma operação bit a bit que deixa um 1 no resultado somente se os dois bits dos operandos forem 1. Por exemplo, se o registrador $t2 tiver
AND Uma operação lógica bit a bit com dois operandos, que calcula um 1 somente se houver um 1 em ambos os operandos.
e o registrador $t1 tiver
então, depois de executar a instrução MIPS
o valor do registrador $t0 seria
Como você pode ver, o AND pode aplicar um padrão de bits a um conjunto de bits para forçar 0s onde houver um 0 no padrão de bits. Esse padrão de bits, em conjunto com o AND, tradicionalmente é chamado de máscara, pois a máscara “oculta” alguns bits. Para colocar um valor em um desses 0s, existe o dual do AND, chamado OR. Essa é uma operação bit a bit, que coloca 1 no resultado se qualquer um dos bits do operando for 1. Exemplificando, se os registradores $t1 e $t2 não tiverem sido alterados do exemplo anterior, o resultado da instrução MIPS
é este valor no registrador $t0:
OR Uma operação lógica bit a bit com dois operandos, que calcula um 1 se houver um 1 em qualquer um dos operandos.
A última operação lógica é um contrário. O NOT apanha um operando e coloca um 1 no resultado se um bit do operando for 0, e vice-versa. Usando nossa notação anterior, ele calcula .
NOT Uma operação lógica bit a bit com um operando, que inverte os bits; ou seja, ela substitui cada 1 por um 0, e cada 0 por um 1.
NOT Uma operação lógica bit a bit com dois operandos, que calcula o NOT do OR dos dois operandos. Ou seja, ela calcula um 1 somente se houver um 0 em ambos os operandos. Acompanhando o formato de três operandos, os projetistas do MIPS decidiram incluir a instrução NOR (NOT OR) no lugar de NOT. Se um operando for zero, então ele é equivalente a NOT: A NOR 0 = NOT (A OR 0) = NOT (A). Se o registrador $t1 não tiver mudado desde o exemplo anterior e o registrador $t3 tiver o valor 0, o resultado da instrução MIPS
é este valor no registrador $t0:
A Figura 2.8 mostrou o relacionamento entre os operadores em C e Java e as instruções MIPS. As constantes são úteis nas operações lógicas AND e OR, assim como nas operações aritméticas, de modo que o MIPS também oferece as instruções and imediato (andi) e or imediato (ori). As constantes são raras para NOR, pois seu uso principal é inverter os bits de um único operando; assim, a arquitetura do conjunto de instruções do MIPS não possui uma versão imediata do NOR.
Detalhamento O conjunto de instruções MIPS completo também inclui exclusive or (XOR), que define o bit como 1 quando dois bits correspondentes diferem, e como 0 quando eles são iguais. C permite que campos de bit ou campos sejam definidos dentro das palavras, ambos permitindo que os objetos sejam empacotados com uma palavra e combinem com uma interface imposta externamente, como um dispositivo de E/S. Todos os campos precisam caber dentro de uma única palavra. Os campos recebem inteiros sem sinal que podem ser tão curtos quanto 1 bit. Os compiladores C inserem e extraem campos usando instruções lógicas no MIPS: and, or, sll e srl.
Detalhamento AND lógico imediato e OR lógico imediato colocam 0s nos 16 bits superiores para formar uma constante de 32 bits, diferente do add imediato, que realiza extensão de sinal.
Verifique você mesmo Que operações podem isolar um campo em uma word? 1. AND 2. Um deslocamento à esquerda seguido por um deslocamento à direita
2.7. Instruções para tomada de decisões A utilidade de um computador automático se encontra na possibilidade de usar determinada sequência de instruções repetidamente; o número de vezes em que ela é repetida depende dos resultados do cálculo. Essa escolha pode depender do sinal de um número (zero é considerado positivo para as finalidades da máquina). Consequentemente, apresentamos uma [instrução] (a [instrução] de transferência condicional) que, dependendo do sinal de determinado número, causa a execução de uma dentre duas rotinas. Burks, Goldstine e von Neumann, 1947
O que distingue um computador de uma calculadora simples é a sua capacidade
de tomar decisões. Com base nos dados de entrada e nos valores criados durante o cálculo, diferentes instruções são executadas. A tomada de decisão normalmente é representada nas linguagens de programação usando a instrução if, às vezes combinadas com instruções go to e rótulos (labels). O assembly do MIPS inclui duas instruções para tomada de decisões, semelhantes a uma instrução if com um go to. A primeira instrução é:
Essa instrução significa ir até a instrução chamada L1 se o valor no registrador1 for igual ao valor no registrador2. O mnemônico beq significa branch if equal (desviar se for igual). A segunda instrução é:
Ela significa ir até a instrução chamada L1 se o valor no registrador1 não for igual ao valor no registrador2. O mnemônico bne significa branch if not equal (desviar se não for igual). Essas duas instruções tradicionalmente são denominadas desvios condicionais.
desvio condicional Uma instrução que requer a comparação de dois valores e que leva em conta uma transferência de controle subsequente para um novo endereço no programa, com base no resultado da comparação.
Compilando if-then-else em desvios condicionais Exemplo No segmento de código a seguir, f, g, h, i e j são variáveis. Se as cinco variáveis de f a j correspondem aos cinco registradores de $s0 a $s4, qual é o código MIPS compilado para esta instrução if em C?
Resposta A Figura 2.9 é um fluxograma de como deve ser o código MIPS. A primeira expressão compara a igualdade, de modo que poderíamos querer desviar se os registradores forem a instrução de igual (beq). De modo geral, o código será mais eficiente se testarmos a condição oposta ao desvio no lugar do código que realiza a parte then subsequente do if (o rótulo Else é definido a seguir) e assim usamos o desvio se os registradores forem a instrução de não igual (bne):
FIGURA 2.9 Ilustração das opções na instrução if acima. A caixa da esquerda corresponde à parte then da instrução if, e a caixa da direita corresponde à parte else.
A próxima instrução de atribuição realiza uma única operação, e se todos os
operandos estiverem em registradores, é apenas uma instrução:
Agora, precisamos ir até o final da instrução if. Este exemplo apresenta outro tipo de desvio, normalmente chamado desvio incondicional. Essa instrução diz que o processador sempre deverá seguir o desvio. Para distinguir entre os desvios condicionais e incondicionais, o nome MIPS para esse tipo de instrução é jump, abreviado como j (o rótulo Exit é definido a seguir).
A instrução de atribuição na parte else da instrução if pode novamente ser compilada para uma única instrução. Só precisamos anexar um rótulo Else a essa instrução. Também mostramos o rótulo Exit que está após essa instrução, mostrando o final do código compilado de if-then-else:
Observe que o montador alivia o compilador e o programador assembly do trabalho de calcular endereços para os desvios, assim como evita o cálculo dos endereços de dados para loads e stores (Seção 2.12).
Interface hardware/software Os compiladores constantemente criam desvios e rótulos onde não aparecem na linguagem de programação. Evitar o trabalho de escrever rótulos e desvios explícitos é um benefício em linguagens de programação de alto nível e um dos motivos para a codificação ser mais rápida nesse nível.
Loops
Loops Decisões são importantes tanto para escolher entre duas alternativas — como encontramos nas instruções if — quanto para repetir um cálculo — como nos loops. As mesmas instruções assembly são os blocos de montagem para os dois casos.
Compilando um loop WHILE em C Exemplo Aqui está um loop tradicional em C:
Suponha que i e K correspondam aos registradores $s3 e $s5 e a base do array save esteja em $s6. Qual é o código assembly MIPS correspondente a esse segmento C?
Resposta O primeiro passo é carregar save[i] em um registrador temporário. Antes que possamos carregar save[i] em um registrador temporário, precisamos ter seu endereço. Antes que possamos somar i à base do array save para formar o endereço, temos de multiplicar o índice i por 4, em razão do problema do endereçamento em bytes. Felizmente, podemos usar o deslocamento lógico à esquerda, pois o deslocamento em 2 bits à esquerda multiplica por 22 ou 4 (Seção “Operações Lógicas”, anteriormente neste capítulo). Precisamos acrescentar o rótulo Loop para podermos desviar de volta a essa instrução no final do loop:
Para obter o endereço de save[i], temos de somar $t1 e a base do array save em $t6:
Agora, podemos usar esse endereço para carregar registrador temporário:
save[i]
em um
A próxima instrução realiza o teste do loop, terminando se save[i] ≠ k:
A próxima instrução soma 1 a i:
O final do loop desvia de volta ao teste do while no início do loop. Simplesmente acrescentamos o rótulo Exit depois dele e terminamos:
(Veja nos exercícios uma otimização para essa sequência.)
Interface hardware/software Essas sequências de instruções que terminam em um desvio são tão fundamentais para a compilação que recebem seu próprio termo: um bloco básico é uma sequência de instruções sem desvios, exceto, possivelmente, no final, e sem destinos de desvio ou rótulos de desvio, exceto, possivelmente, no início. Uma das primeiras fases da compilação é desmembrar o programa em blocos básicos.
bloco básico Uma sequência de instruções sem desvios (exceto, possivelmente, no final) e sem destinos de desvio ou rótulos de desvio (exceto, possivelmente, no início). O teste de igualdade ou desigualdade provavelmente é o teste mais comum, mas às vezes é útil ver se uma variável é menor do que outra. Por exemplo, um loop for pode querer testar se a variável de índice é menor do que 0. Essas comparações são realizadas em assembly do MIPS com uma instrução que compara dois registradores e atribui 1 a um terceiro registrador se o primeiro for menor do que o segundo; caso contrário, é atribuído 0. A instrução MIPS é chamada set on less than (atribuir se menor que) ou slt. Por exemplo,
significa que é atribuído 1 ao registrador $t0 se o valor no registrador $s3 for menor do que o valor no registrador $s4; caso contrário, é atribuído 0 ao registrador $t0. Operadores constantes são comuns nas comparações, de modo que existe uma versão imediata da instrução “set on less than”. Para testar se o registrador $s2 é menor do que a constante 10, podemos simplesmente escrever
Interface hardware/software Os compiladores MIPS utilizam as instruções slt, slti, beq, bne e o valor fixo 0 (sempre à disposição com a leitura do registrador $zero) para criar todas as condições relativas: igual, diferente, menor que, menor ou igual, maior que, maior ou igual. Atentando para a advertência de von Neumann quanto à simplicidade do “equipamento”, a arquitetura do MIPS não inclui “desvio se menor que”, pois
isso é muito complicado; ou ela esticaria o tempo do ciclo de clock ou exigiria ciclos de clock extras por instrução. Duas instruções mais rápidas são mais úteis.
Interface hardware/software As instruções de comparação precisam lidar com a dicotomia entre números com sinal e sem sinal. Às vezes, um padrão de bits com 1 no bit mais significativo representa um número negativo e, naturalmente, é menor que qualquer número positivo, que precisa ter um 0 no bit mais significativo. Com inteiros sem sinal, por outro lado, um 1 no bit mais significativo representa um número que é maior que qualquer um que comece com um 0. (Logo tiraremos proveito desse significado dual do bit mais significativo para reduzir o custo da verificação dos limites do array.) MIPS oferece duas versões da comparação “set on less than” para tratar dessas alternativas. Set on less than (slt) e set on less than immediate (slti) funcionam com inteiros com sinal. Os inteiros sem sinal são comparados por meio de set on less than unsigned (sltu) e set on less than immediate unsigned (sltiu).
Comparação de números com sinal e sem sinal Exemplo Suponha que o registrador $s0 tenha o número binário
e que o registrador $s1 tenha o número binário
Quais são os valores dos registradores instruções?
$t0
e
$t1
após essas duas
Resposta O valor no registrador $s0 representa -1dec se for um inteiro e 4.294.967.295dec se for um inteiro sem sinal. O valor no registrador $s1 representa 1dec em qualquer caso. O registrador $t0 tem o valor 1, pois –1dec < 1dec, e o registrador $t1 tem o valor 0, desde 4.294.967.295dec > 1dec. Tratar números com sinal como se fossem sem sinal é um modo de baixo custo para verificar se 0 ≤ x < y, que corresponde à verificação de índice fora de limite dos arrays. O principal é que os inteiros negativos na notação de complemento de dois se parecem com números grandes na notação sem sinal; ou seja, o bit mais significativo é um bit de sinal na primeira notação, mas uma grande parte do número na segunda. Assim, uma comparação sem sinal de x < y também verifica se x é negativo, bem como se x é menor que y.
Atalho para verificação de limites Exemplo Use este atalho para reduzir uma verificação de índice fora dos limites: salte para IndexOutOfBounds se $s1 ≥ $t2 ou se $s1 é negativo.
Resposta O código de verificação só usa sltu para realizar as duas verificações:
Instrução Case/Switch
A maioria das linguagens de programação possui uma instrução case ou switch, para o programador poder selecionar uma dentre muitas alternativas, dependendo de um único valor. O modo mais simples de implementar switch é por meio de uma sequência de testes condicionais, transformando a instrução switch em uma cadeia de instruções if-then-else. Às vezes, as alternativas podem ser codificadas de forma mais eficiente como uma tabela de endereços de sequências de instruções alternativas, chamada tabela de endereços de desvio ou tabela de desvio, e o programa só precisa indexar na tabela e depois desviar para a sequência apropriada. A tabela de desvios é, então, apenas um array de palavras com endereços que correspondem aos rótulos no código. O programa carrega a entrada apropriada a partir da tabela de desvio para um registrador. Depois, ele precisa desviar usando o endereço no registrador. Para apoiar tais situações, computadores como o MIPS incluem uma instrução jump register (jr), significando um desvio incondicional para o endereço especificado em um registrador. Depois desvia para o endereço apropriado usando essa instrução. Veremos um uso ainda mais popular de jr na próxima seção.
tabela de endereços de desvio Também chamada de tabela de desvios. Uma tabela de endereços de sequências de instruções alternativas.
Interface hardware/software Embora existam muitas instruções para decisões e loops em linguagens de programação como C e Java, a instrução básica que as implementa no nível do conjunto de instruções é o desvio condicional.
Detalhamento Se você já ouviu falar em delayed branches, explicados no Capítulo 4, não se preocupe: o montador do MIPS os torna invisíveis ao programador assembly.
Verifique você mesmo I. A linguagem C possui muitas instruções para decisões e loops, enquanto o MIPS possui poucas. Quais dos seguintes itens explicam ou não esse
desequilíbrio? Por quê? 1. Mais instruções de decisão tornam o código mais fácil de ler e entender. 2. Menos instruções de decisão simplificam a tarefa da camada inferior responsável pela execução. 3. Mais instruções de decisão significam menos linhas de código, o que geralmente reduz o tempo de codificação. 4. Mais instruções de decisão significam menos linhas de código, o que geralmente resulta na execução de menos operações. II. Por que a linguagem C oferece dois conjuntos de operadores para AND (& e &&) e dois conjuntos de operadores para OR (| e ||), enquanto o MIPS não faz isso? 1. As operações lógicas AND e OR implementam & e |, enquanto os desvios condicionais implementam && e ||. 2. A afirmativa anterior é o contrário: && e || correspondem a operações lógicas, enquanto & e | são mapeados para desvios condicionais. 3. Elas são redundantes e significam a mesma coisa: && e || são simplesmente herdados da linguagem de programação B, a antecessora do C.
2.8. Suporte a procedimentos no hardware do computador Um procedimento ou função é uma ferramenta que os programadores utilizam a fim de estruturar programas, tanto para torná-los mais fáceis de entender quanto para permitir que o código seja reutilizado. Os procedimentos permitem que o programador se concentre em apenas uma parte da tarefa de cada vez, com os parâmetros atuando como uma interface entre o procedimento e o restante do programa e dos dados, pois eles passam valores e retornam resultados. Os procedimentos são uma forma de implementar abstração no software.
procedimento Uma sub-rotina armazenada que realiza uma tarefa com base nos parâmetros que lhe são passados.
Você pode pensar em um procedimento como um espião que sai com um plano secreto, adquire recursos, realiza a tarefa, cobre seus rastros e depois retorna ao ponto de origem com o resultado desejado. Nada mais deverá ter sido alterado depois que a missão terminar. Além do mais, um espião opera apenas sobre aquilo que ele “precisa saber”, de modo que não pode fazer suposições sobre seu patrão. 1. De modo semelhante, na execução de um procedimento, o programa precisa seguir estas seis etapas: 2. Colocar parâmetros em um lugar onde o procedimento possa acessá-los. 3. Transferir o controle para o procedimento. 4. Adquirir os recursos de armazenamento necessários para o procedimento. 5. Realizar a tarefa desejada. 6. Colocar o valor de retorno em um local onde o programa que o chamou possa acessá-lo. 7. Retornar o controle para o ponto de origem, pois um procedimento pode ser chamado de vários pontos em um programa. Como já dissemos, os registradores são o local mais rápido para manter dados em um computador, de modo que queremos usá-los ao máximo. O software do
MIPS utiliza a seguinte convenção na alocação de seus 32 registradores: ▪ $a0-$a3: quatro registradores de argumento, para passar parâmetros ▪ $v0-$v1: dois registradores de valor, para valores de retorno ▪ $ra: um registrador de endereço de retorno, para retornar ao ponto de origem Além de alocar esses registradores, o assembly do MIPS inclui uma instrução apenas para os procedimentos: ela desvia para um endereço e simultaneamente salva o endereço da instrução seguinte no registrador $ra. A instrução de jumpand-link (jal) é escrita simplesmente como
instrução de jump-and-link Uma instrução que salta para um endereço e simultaneamente salva o endereço da instrução seguinte em um registrador ($ra no MIPS). A parte do link no nome da instrução significa que um endereço ou link é formado de modo a apontar para o local de chamada, permitindo que o procedimento retorne ao endereço correto. Esse “link”, armazenado no registrador $ra, é denominado endereço de retorno. O endereço de retorno é necessário porque o mesmo procedimento poderia ser chamado de várias partes do programa.
endereço de retorno Um link para o local de chamada, permitindo que um procedimento retorne ao endereço correto; no MIPS, ele é armazenado no registrador $ra. Para dar suporte a tais situações, computadores como o MIPS utilizam uma instrução de jump register (jr), apresentada anteriormente para ajudar com as instruções case, significando um desvio incondicional para o endereço especificado em um registrador:
A instrução de jump register pula para o endereço armazenado no registrador $ra — que é exatamente o que queremos. Assim, o programa que chama, ou caller, coloca os valores de parâmetro em $a0-$a3 e utiliza jal X para desviar para o procedimento X (às vezes denominado callee). O callee, então, realiza os cálculos, coloca os resultados em $v0-$v1 e retorna o controle para o caller usando jr $ra.
caller O programa que instiga um procedimento e oferece os valores de parâmetro necessários.
callee Um procedimento que executa uma série de instruções armazenadas com base nos parâmetros fornecidos pelo caller e depois retorna o controle para o caller novamente. Num programa armazenado é necessário ter um registrador para manter o endereço da instrução atual sendo executada. Por motivos históricos, esse registrador quase sempre é denominado contador de programa, abreviado como PC (Program Counter) na arquitetura MIPS, embora um nome mais sensato teria sido registrador de endereço de instrução. A instrução jal salva o PC + 4 no registrador $ra para o link com a instrução seguinte, a fim de preparar o retorno do procedimento.
contador de programa (PC) O registrador que contém o endereço da instrução sendo executada no programa.
Usando mais registradores Suponha que um compilador precise de mais registradores para um procedimento do que os quatro registradores para argumentos e os dois para valores de retorno. Como temos de cobrir nossos rastros após o término desta missão, quaisquer registradores necessários ao caller deverão ser restaurados aos valores que possuíam antes de o procedimento ser chamado. Essa situação é um
exemplo em que são usados os spilled registers em memória, conforme mencionamos na Seção “Interface hardware/software”. A estrutura de dados ideal para armazenar os spilled registers é uma pilha — uma fila do tipo “último a entrar, primeiro a sair”. Uma pilha precisa de um ponteiro para o endereço alocado mais recentemente na pilha, a fim de mostrar onde o próximo procedimento deverá colocar os spilled registers ou onde os valores antigos dos registradores estão localizados. O stack pointer é ajustado em uma palavra para cada registrador salvo ou restaurado. O software MIPS reserva o registrador 29 para o stack pointer, dando-lhe o nome óbvio $sp. As pilhas são tão comuns que possuem seus próprios termos para transferir dados da pilha e para ela: colocar dados na pilha é denominado push, e remover dados da pilha é denominado pop.
pilha (stack) Uma estrutura de dados utilizada para armazenar os registradores, organizada como uma fila do tipo “último a entrar, primeiro a sair”.
stack pointer Um valor indicando o endereço alocado mais recentemente em uma pilha, que mostra onde os registradores devem ser armazenados ou onde os valores antigos dos registradores podem ser localizados.
push Acrescentar elemento à pilha.
pop Remover elemento da pilha. Por motivos históricos, as pilhas “crescem” de endereços maiores para endereços menores. Essa convenção significa que você põe valores na pilha subtraindo do valor do stack pointer. Somar ao stack pointer diminui essa pilha, removendo seus valores.
Compilando um procedimento em C que não
chama outro procedimento Exemplo Vamos transformar o exemplo da Seção 2.2 em um procedimento em C:
Qual é o código assembly do MIPS compilado?
Resposta As variáveis de parâmetro g, h, i e j correspondem aos registradores de argumento $a0, $a1, $a2 e $a3, e f corresponde a $s0. O programa compilado começa com o rótulo do procedimento:
O próximo passo é salvar os registradores usados pelo procedimento. A instrução de atribuição em C no corpo do procedimento é idêntica ao exemplo da Seção 2.2, que usa dois registradores temporários. Assim, precisamos salvar três registradores: $s0, $t0 e $t1. “Empilhamos” os valores antigos, criando espaço para três palavras (12 bytes) na pilha e depois as armazenamos:
A Figura 2.10 mostra a pilha antes, durante e após a chamada do procedimento.
FIGURA 2.10 Os valores do stack pointer e a pilha (a) antes, (b) durante e (c) após a chamada do procedimento. O stack pointer sempre aponta para o “topo” da pilha ou para a última palavra na pilha neste desenho.
As três instruções seguintes correspondem ao corpo do procedimento, que segue o exemplo da Seção 2.2:
Para retornar o valor de f, nós o copiamos para um registrador de valor de retorno:
Antes de retornar, restauramos os três valores antigos dos registradores que salvamos, desempilhando-os:
O procedimento termina com um jump register usando o endereço de retorno:
No exemplo anterior, usamos registradores temporários e consideramos que seus valores antigos precisam ser salvos e restaurados. Para evitar salvar e restaurar um registrador cujo valor nunca é utilizado, o que poderia acontecer com um registrador temporário, o software do MIPS separa 18 dos registradores em dois grupos: ▪ $t0–$t9: registradores temporários que não são preservados pelo callee (procedimento chamado) em uma chamada de procedimento ▪ $s0–$s7: registradores salvos que precisam ser preservados em uma chamada de procedimento (se forem usados, o calee os salva e restaura) Essa convenção simples reduz o armazenamento de registradores. No exemplo anterior, como o caller não espera que os registradores $t0 e $t1 sejam preservados durante uma chamada de procedimento, podemos descartar dois stores e dois loads do código. Ainda temos de salvar e restaurar $s0, pois o procedimento chamado deve considerar que o caller precisa de seu valor.
Procedimentos aninhados
Os procedimentos que não chamam outros são denominados procedimentos folha. A vida seria simples se todos os procedimentos fossem procedimentos folha, mas não são. Assim como um espião poderia empregar outros espiões como parte de uma missão, que, por sua vez, poderiam utilizar ainda mais espiões, os procedimentos também chamam outros procedimentos. Além do mais, os procedimentos recursivos ainda chamam “clones” de si mesmos. Assim como precisamos ter cuidado ao usar registradores nos procedimentos, também precisamos ter mais cuidado ao chamar procedimentos não folha. Por exemplo, suponha que o programa principal chame o procedimento A com um argumento 3, colocando o valor 3 no registrador $a0 e depois usando jal A. Depois, suponha que o procedimento A chame o procedimento B por meio de jal B com um argumento 7, também colocado em $a0. Como A ainda não terminou sua tarefa, existe um conflito com relação ao uso do registrador $a0. De modo semelhante, existe um conflito em relação ao endereço de retorno no registrador $ra, pois ele agora tem o endereço de retorno para B. A menos que tomemos medidas para evitar o problema, esse conflito eliminará a capacidade do procedimento A de retornar para o procedimento que o chamou. Uma solução é empilhar todos os outros registradores que precisam ser preservados, assim como fizemos com os registradores salvos. O caller empilha quaisquer registradores de argumento ($a0–$a3) ou registradores temporários ($t0–$t9) que sejam necessários após a chamada. O callee empilha o registrador do endereço de retorno $ra e quaisquer registradores salvos ($s0–$s7) usados por ele. O stack pointer $sp é ajustado para levar em consideração a quantidade de registradores colocados na pilha. No retorno, os registradores são restaurados da memória e o stack pointer é reajustado.
Compilando um procedimento C recursivo, mostrando a ligação do procedimento aninhado Exemplo Vamos realizar um procedimento recursivo que calcula o fatorial:
Qual é o código assembly do MIPS?
Resposta A variável de parâmetro n corresponde ao registrador de argumento $a0. O programa compilado começa com o rótulo do procedimento e depois salva dois registradores na pilha, o endereço de retorno e $a0:
Na primeira vez que fact é chamado, sw salva um endereço do programa que chamou fact. As duas instruções seguintes testam se n é menor do que 1, indo para L1 se n ≥1.
Se n for menor do que 1, fact retorna 1, colocando 1 em um registrador de valor: ele soma 1 a 0 e coloca essa soma em $v0. Depois, ele retira os dois valores salvos da pilha e desvia para o endereço de retorno:
Antes de retirar dois itens da pilha, poderíamos ter restaurado $a0 e $ra. Como $a0 e $ra não mudam quando n é menor do que 1, pulamos essas instruções. Se n não for menor do que 1, o argumento n é diminuído e depois fact é chamado novamente com o valor reduzido.
A próxima instrução é onde fact retorna. Agora, o endereço de retorno antigo e o argumento antigo são restaurados, juntamente com o stack pointer:
Em seguida, o registrador de valor $v0 recebe o produto do argumento antigo $a0 e o valor atual do registrador de valor. Consideramos que exista uma instrução de multiplicação à disposição, embora isso não seja explicado antes do Capítulo 3:
Finalmente, fact salta novamente para o endereço de retorno:
Interface hardware/software Uma variável em C é um local na memória e sua interpretação depende tanto do seu tipo quanto da sua classe de armazenamento. Alguns exemplos são inteiros e caracteres (Seção 2.9). A linguagem C possui duas classes de armazenamento: automática e estática. As variáveis automáticas são locais a um procedimento e são descartadas quando o procedimento termina. As variáveis estáticas permanecem durante entradas e saídas de procedimentos. As variáveis C declaradas fora de todos os procedimentos são consideradas estáticas, assim como quaisquer variáveis declaradas por meio da palavra reservada static. As outras são automáticas. Para simplificar o acesso aos dados estáticos, o software do MIPS reserva outro registrador, chamado ponteiro global ou $gp.
ponteiro global O registrador reservado para apontar para a área estática. A Figura 2.11 resume o que é preservado em uma chamada de procedimento. Observe que vários esquemas preservam a pilha, garantindo que o caller receberá os mesmos dados em um load da pilha que foram armazenados nela. A pilha acima de $sp é preservada simplesmente verificando se o procedimento chamado não escreve acima de $sp; $sp é preservado pelo procedimento chamado, somando-se exatamente o mesmo valor que foi subtraído dele, e os outros registradores são preservados por serem salvos na pilha (se forem usados) e restaurados de lá.
FIGURA 2.11 O que é e o que não é preservado durante uma chamada de procedimento.
Se o software contar com o registrador de frame pointer ou com o registrador de ponteiro global, discutidos nas próximas seções, eles também serão preservados.
Alocando espaço para novos dados na pilha A complexidade final é que a pilha também é utilizada para armazenar variáveis que são locais ao procedimento, que não cabem nos registradores, como arrays ou estruturas locais. O segmento da pilha que contém os registradores salvos e as variáveis locais de um procedimento é chamado frame de procedimento ou registro de ativação. A Figura 2.12 mostra o estado da pilha antes, durante e após a chamada de um procedimento.
FIGURA 2.12 Ilustração da alocação de pilha (a) antes, (b) durante e (c) após a chamada de um procedimento. O frame pointer ($fp) aponta para a primeira palavra do frame, normalmente um registrador de argumento salvo, e o stack pointer ($sp) aponta para o topo da pilha. A pilha é ajustada de modo a criar espaço para todos os registradores salvos e quaisquer variáveis locais residentes na memória. Como o stack pointer pode mudar durante a execução do programa, é mais fácil para os programadores referenciarem variáveis por meio do frame pointer estável, embora isso também pudesse ser feito por meio do stack pointer e um pouco de aritmética de endereços. Se não houver variáveis locais na pilha dentro de um procedimento, o compilador ganhará tempo não atribuindo um
endereço ao frame pointer, e depois, restaurando-o. Quando um frame pointer é usado, ele é inicializado usando o endereço que está no $sp em uma chamada, e o $sp é restaurado usando o valor do $fp. Essa informação também aparece na Coluna 4 do Guia de Referência do MIPS, no final deste livro.
frame de procedimento Também chamado registro de ativação. O segmento da pilha contendo os registradores salvos e as variáveis locais de um procedimento. Alguns softwares MIPS utilizam o frame pointer ($fp) a fim de apontar para a primeira word do registro de ativação de um procedimento. O stack pointer poderia mudar durante o procedimento, e assim as referências a uma variável local na memória poderiam ter offsets diferentes, dependendo de onde estiverem no procedimento, o que torna o procedimento mais difícil de entender. Como alternativa, um frame pointer oferece um registrador base estável dentro de um procedimento para as referências locais à memória. Observe que um registro de ativação aparece na pilha independentemente de o frame pointer explícito ser utilizado. Evitamos o $fp impedindo mudanças no $sp dentro de um procedimento: em nossos exemplos, a pilha é ajustada apenas na entrada e na saída do procedimento.
frame pointer Um valor indicando o local dos registradores salvos e as variáveis locais para um determinado procedimento.
Alocando espaço para novos dados no heap Além das variáveis automáticas que são locais aos procedimentos, os programadores de C precisam de espaço na memória para as variáveis globais e para estruturas de dados dinâmicas. A Figura 2.13 mostra a convenção do MIPS para a alocação de memória. A pilha começa na parte alta da memória e cresce para baixo. A primeira parte da extremidade baixa da memória é reservada, seguida pelo lar do código de máquina do MIPS, tradicionalmente denominado segmento de texto. Acima do código existe o segmento de dados estáticos, que é o local para constantes e outras variáveis estáticas. Embora os arrays
costumem ter um tamanho fixo e, portanto, correspondam muito bem ao segmento de dados estático, estruturas de dados como listas encadeadas costumam crescer e diminuir durante suas vidas. O segmento para tais estruturas de dados é tradicionalmente chamado de heap e fica posicionado logo a seguir na memória. Observe que essa alocação permite que a pilha e o heap cresçam um em direção ao outro, permitindo, assim, o uso eficiente da memória enquanto os dois segmentos aumentam e diminuem.
FIGURA 2.13 A alocação de memória do MIPS para programas e dados. Esses endereços são apenas uma convenção do software e não fazem parte da arquitetura MIPS. De cima para baixo, o stack pointer é inicializado com 7fff fffchexa e cresce para baixo, em direção ao segmento de dados. Na outra extremidade, o código do programa (“texto”) começa em 0040 0000hexa. Os dados estáticos começam em 1000 0000hexa. Os dados dinâmicos, alocados por malloc em C e por new em Java, vêm em seguida e crescem para cima em direção à pilha, em uma área chamada
heap. O ponteiro global, $gp, é definido como endereço para facilitar o acesso aos dados. Ele é inicializado com 1000 8000hexa para poder acessar de 1000 0000hexa até 1000 ffffhexa usando os offsets de 16 bits positivos e negativos a partir do $gp. Essa informação também aparece na Coluna 4 do Guia de Referência do MIPS, no final deste livro.
segmento de texto O segmento de um arquivo-objeto Unix que contém o código em linguagem de máquina para as rotinas do arquivo-fonte. A linguagem C aloca e libera espaço no heap com funções explícitas. malloc() aloca espaço no heap e retorna um ponteiro para ela, e free() libera o espaço no heap para o qual o ponteiro está apontando. A alocação da memória é controlada por programas em C e essa é a fonte de muitos bugs comuns e difíceis de serem encontrados. Esquecer de liberar espaço ocasiona um “vazamento de memória”, que, por fim, pode ocupar tanta memória que venha a causar a falha do sistema operacional. Liberar espaço muito cedo ocasiona “ponteiros pendentes”, podendo fazer os ponteiros apontarem para áreas que o programa nunca desejou. Java utiliza alocação de memória e coleta de lixo automáticas, justamente para evitar esses bugs. A Figura 2.14 resume as convenções de registrador para o assembly do MIPS. Esta convenção é outro exemplo de tornar o caso comum veloz: a maioria dos procedimentos pode ser satisfeita com até 4 argumentos, 2 registradores para um valor de retorno, 8 registradores salvos e 10 registradores temporários sem sequer ir para a memória.
FIGURA 2.14 Convenções de registradores MIPS. O registrador 1, chamado $at, é reservado para o montador (Seção 2.12), e os registradores 26-27, chamados $k0-$k1, são reservados para o sistema operacional. Essa informação também aparece na Coluna 2 do Guia de Referência do MIPS, no final deste livro.
Detalhamento E se houver mais do que quatro parâmetros? A convenção do MIPS é colocar os parâmetros extras na pilha, logo acima do stack pointer. O procedimento, então, espera que os quatro primeiros parâmetros estejam nos registradores de $a0 a $a3, e que o restante esteja na memória, endereçável por meio do frame pointer. Conforme dissemos na legenda da Figura 2.12, o frame pointer é conveniente porque todas as referências a variáveis na pilha dentro de um procedimento terão o mesmo offset. Contudo, o frame pointer não é necessário. O compilador C para MIPS sob licença GNU utiliza um frame pointer, mas não o compilador C do MIPS; ele trata o registrador 30 como outro registrador de valor salvo ($a8).
Detalhamento Alguns procedimentos recursivos podem ser implementados iterativamente sem o uso de recursão. A iteração pode melhorar significativamente o desempenho, removendo o overhead associado a chamadas de procedimento. Por exemplo, considere um procedimento usado para acumular uma soma:
Considere a chamada de procedimento sum(3,0). Isso resultará em chamadas recursivas a sum(2,3), sum(1,5) e sum(0,6), e depois o resultado 6 será retornado quatro vezes. Essa chamada recursiva de sum é conhecida como tail call e esse exemplo de uso da recursão tail pode ser implementado de modo muito eficiente (suponha que $a0 = n e $a1 = acc):
Verifique você mesmo Quais das seguintes afirmações sobre C e Java geralmente são verdadeiras? 1. Os programadores C gerenciam os dados explicitamente, enquanto isso é automático em Java. 2. A linguagem C leva a mais problemas de ponteiro e vazamento de memória do que Java.
2.9. Comunicando-se com as pessoas
!(@ | = > (wow open tab at bar is great) Quarta linha do poema de teclado “Hatless Atlas”, 1991 (alguns dão nomes aos caracteres ASCII: “!” é “wow”, “(” é open, “|” é bar, e assim por diante) Os computadores foram inventados para devorar números, mas, assim que se tornaram comercialmente viáveis, eles foram usados para processar textos. A maioria dos computadores hoje utiliza bytes de 8 bits para representar caracteres; o American Standard Code for Information Interchange (ASCII) é a representação que quase todos seguem. A Figura 2.15 resume o código ASCII.
FIGURA 2.15 Representação dos caracteres no código ASCII. Observe que as letras maiúsculas e minúsculas diferem exatamente em 32; essa observação pode levar a atalhos na verificação ou mudança entre maiúsculas e minúsculas. Os valores não mostrados incluem caracteres de formatação. Por exemplo, 8 representa backspace, 9 representa o caractere de tabulação e 13, um carriage return. Outro valor útil é 0 para null, o valor que a linguagem de programação C utiliza para marcar o final de uma string. Essa informação também aparece na Coluna 3 do Guia de Referência do MIPS, no final deste livro.
ASCII e números binários
Exemplo Poderíamos representar números como strings de dígitos ASCII em vez de inteiros. Em quanto o armazenamento aumenta se o número 1 bilhão for representado em ASCII em vez de inteiro com 32 bits?
Resposta Um bilhão é 1.000.000.000, de modo que ele precisaria de 10 dígitos ASCII, cada um com 8 bits de extensão. Assim, a expansão no armazenamento seria (10 × 8)/32 ou 2,5. Além da expansão no armazenamento, o hardware para somar, subtrair, multiplicar e dividir esses números decimais é difícil e consumiria mais energia. Essas dificuldades explicam por que os profissionais de computação são levados a crer que o binário é natural e que o computador decimal ocasional é bizarro. Diversas instruções podem extrair um byte de uma palavra, de modo que load word e store word são suficientes para transferir bytes e também palavras. Entretanto, em razão da popularidade do texto em alguns programas, o MIPS oferece instruções para mover bytes. Load byte (lb) lê um byte da memória, colocando-o nos 8 bits mais à direita de um registrador. Store byte (sb) separa o byte mais à direita de um registrador e o escreve na memória. Assim, copiamos um byte com a sequência
Os caracteres normalmente são combinados em strings, que possuem uma quantidade variável de caracteres. Existem três opções para representar uma string: (1) a primeira posição da string é reservada para indicar o tamanho de uma string, (2) uma variável acompanhante possui o tamanho da string (como em uma estrutura) ou (3) a última posição da string é ocupada por um caractere que serve para marcar o final da string. A linguagem C utiliza a terceira opção, terminando uma string com um byte cujo valor é 0 (denominado null, em ASCII). Assim, a string “Cal” é representada em C pelos 4 bytes a seguir, em forma de números decimais: 67, 97, 108, 0. (Como veremos, Java utiliza a
primeira opção.)
Compilando um procedimento de cópia de string, para demonstrar o uso de strings em C Exemplo O procedimento strcpy copia a string y para a string x, usando a convenção de término com byte nulo da linguagem C:
Qual é o código assembly correspondente no MIPS?
Resposta A seguir está o segmento básico em código assembly do MIPS. Considere que os endereços base para os arrays x e y são encontrados em $a0 e $a1, enquanto i está em $s0. strcpy ajusta o stack pointer e depois salva o registrador de valores salvos $s0 na pilha:
Para inicializar i como 0, a próxima instrução define $s0 como 0, somando 0 a 0 e colocando essa soma em $s0:
Esse é o início do loop. O endereço de y[i] é formado inicialmente pela soma de i a y[]:
Observe que não temos de multiplicar i por 4, pois y é um array de bytes, e não de palavras, como nos exemplos anteriores. Para carregar o caractere em y[i], usamos load byte unsigned, que coloca o caracter em $t2:
Um cálculo de endereço semelhante coloca o endereço de x[i] em $t3, e depois o caracter em $t2 é armazenado nesse endereço.
Em seguida, saímos do loop se o caracter foi 0; ou seja, esse é o último caracter da string:
Se não, incrementamos i e voltamos ao loop:
Se não voltamos, então esse foi o último caracter da string; restauramos $s0 e o stack pointer, para depois retornar.
As cópias de string normalmente utilizam ponteiros no lugar de arrays em C, para evitar as operações com i no código anterior. Veja, na Seção 2.14, uma explicação sobre arrays e ponteiros. Como o procedimento strcpy anterior é um procedimento folha, o compilador poderia alocar i a um registrador temporário e evitar as operações de salvar e restaurar $s0. Por essa razão, em vez de pensar nos registradores $t como sendo apenas para valores temporários, podemos pensar neles como registradores que o procedimento chamado deve utilizar sempre que for conveniente. Quando um compilador encontra um procedimento folha, ele esgota todos os registradores temporários antes de usar registradores que precisa salvar.
Caracteres e strings em Java Unicode é uma codificação universal dos alfabetos da maior parte das linguagens humanas. A Figura 2.16 é uma lista de alfabetos Unicode; existem tantos alfabetos em Unicode quanto símbolos úteis em ASCII. Para ser mais específico, Java utiliza Unicode para os caracteres. Como padrão, ela utiliza 16 bits a fim de representar um caracter.
FIGURA 2.16 Exemplos de alfabetos em Unicode. O Unicode versão 4.0 possui mais de 160 “blocos”, que é o nome para uma coleção de símbolos. Cada bloco é um múltiplo de 16. Por exemplo, Grego começa em 0370hexa e Cirílico em 0400hexa. As três primeiras colunas mostram 48 blocos que correspondem a linguagens humanas em ordem numérica aproximada no Unicode. A última coluna possui 16 blocos que são multilíngues e não estão em ordem. Uma codificação de 16 bits, chamada UTF-16, é o padrão. Uma codificação de tamanho variável, chamada UTF-8, mantém o subconjunto ASCII como 8 bits e utiliza 16 ou 32 bits para os outros caracteres. UTF-32 utiliza 32 bits por caracter. Para saber mais sobre isso, consulte www.unicode.org.
O conjunto de instruções do MIPS possui instruções explícitas para carregar e armazenar quantidades de 16 bits, chamadas halfwords. Load half (lh) lê uma halfword da memória, colocando-a nos 16 bits mais à direita de um registrador. Assim como load byte, load half (lh) trata a halfword como um número com sinal e, portanto, estende o sinal para preencher os 16 bits mais à esquerda do registrador, enquanto load halfword unsigned (lhu) trabalha com inteiros sem sinal. Assim, lhu é o mais comum dos dois. Store half (sh) separa a halfword correspondente aos 16 bits mais à direita de um registrador e a escreve na memória. Copiamos uma halfword com a sequência
As strings são uma classe padrão do Java, com suporte interno especial e métodos predefinidos para concatenação, comparação e conversão. Ao contrário da linguagem C, o Java inclui uma palavra que indica o tamanho da string, semelhante aos arrays Java.
Detalhamento O software do MIPS tenta manter a pilha alinhada em endereços de palavra, permitindo que o programa sempre use lw e sw (que precisam estar alinhados) para acessar a pilha. Essa convenção significa que uma variável char alocada na pilha ocupa 4 bytes, embora precise de menos. Contudo, uma variável string ou um array de bytes em C agrupará 4 bytes por palavra, e uma variável string ou array de shorts em Java agrupará 2 halfwords por palavra.
Detalhamento Refletindo a natureza internacional da Web, a maioria das páginas web hoje utiliza Unicode ao invés de ASCII.
Verifique você mesmo I. Quais das seguintes afirmações sobre caracteres e strings em C e Java são verdadeiras? 1. Uma string em C utiliza cerca da metade da memória da mesma string em Java. 2. Strings são apenas um nome informal para arrays de uma única dimensão de caracteres em C e Java. 3. As strings em C e Java utilizam null (0) para marcar o fim de uma string. 4. As operações sobre strings, como saber seu tamanho, são mais rápidas em C do que em Java. II. Que tipo de variável que pode conter 1.000.000.000dec ocupa mais espaço na memória? 1. int em C
2. string em C 3. string em Java
2.10. Endereçamento no MIPS para operandos imediatos e endereços de 32 bits Embora manter todas as instruções MIPS com 32 bits simplifique o hardware, existem ocasiões em que seria conveniente ter uma constante de 32 bits ou endereço de 32 bits. Esta seção começa com a solução geral para constantes grandes e depois apresenta as otimizações para endereços de instruções usados em desvios condicionais e jumps.
Operandos imediatos de 32 bits Embora as constantes normalmente sejam curtas e caibam em um campo de 16 bits, às vezes elas são maiores. O conjunto de instruções MIPS inclui a instrução load upper immediate (lui) especificamente para atribuir os 16 bits mais altos de uma constante a um registrador, permitindo que uma instrução subsequente atribua os 16 bits mais baixos da constante. A Figura 2.17 mostra a operação de lui.
FIGURA 2.17 O efeito da instrução lui. A instrução lui transfere o valor do campo de constante imediata de 16 bits para os 16 bits mais à esquerda do registrador, preenchendo os 16 bits de menor ordem (direita) com 0s.
Carregando uma constante de 32 bits Exemplo Qual é o código assembly do MIPS para carregar esta constante de 32 bits no registrador $s0?
Resposta Primeiro, carregaríamos os 16 bits mais altos, que é 61 em decimal, usando a instrução lui:
O valor do registrador $s0 depois disso é
O próximo passo é acrescentar os 16 bits inferiores, cujo valor decimal é 2304:
O valor final no registrador $s0 é o valor desejado:
Interface hardware/software Tanto o compilador quanto o montador precisam desmembrar constantes grandes em partes e depois remontá-las em um registrador. Como você poderia esperar, a restrição de tamanho do campo imediato pode ser um problema para endereços de memória em loads e stores, e também para constantes em instruções imediatas. Se esse trabalho recair para o montador, como acontece para o software do MIPS, então o montador precisa ter um registrador temporário disponível, onde criará valores longos. Esse é um
motivo para o registrador $at (assembler temporary), que é reservado para o montador. Logo, a representação simbólica da linguagem de máquina do MIPS não está mais limitada pelo hardware, mas a qualquer coisa que o criador de um montador decidir incluir (Seção 2.12). Vamos examinar o hardware de perto para explicar a arquitetura do computador, indicando quando usarmos a linguagem avançada do montador que não se encontra no processador.
Detalhamento A criação de constantes de 32 bits requer cuidado. A instrução addi copia o bit mais à esquerda do campo imediato de 16 bits da instrução para todos os 16 bits mais altos de uma palavra. O operador lógico ou imediato, da Seção 2.6, carrega 0s nos 16 bits superiores e, portanto, é usado pelo montador em conjunto com lui para criar constantes de 32 bits.
Endereçamento em desvios condicionais e jumps As instruções de jump no MIPS possuem o endereçamento mais simples possível. Elas utilizam o último formato de instrução do MIPS, chamado tipo J, que consiste em 6 bits para o campo de operação e o restante dos bits para o campo de endereço. Assim,
poderia ser gerada neste formato (normalmente, isso é um pouco mais complicado, como veremos):
em que o valor do código da operação de jump é 2 e o endereço destino é 10000. Ao contrário da instrução de jump, a instrução de desvio condicional precisa
especificar dois operandos além do endereço de desvio. Assim,
é gerada nesta instrução, deixando apenas 16 bits para o endereço de desvio:
Se os endereços do programa tivessem de caber nesse campo de 16 bits, nenhum programa poderia ser maior do que 216, que é muito pequeno para ser uma opção real nos dias atuais. Uma alternativa seria especificar um registrador que sempre seria somado ao endereço de desvio, de modo que uma instrução de desvio pudesse calcular o seguinte:
Essa soma permite que o contador de programa tenha até 232 bits e ainda possa usar desvios condicionais, solucionando o problema do tamanho do endereço de desvio. A questão, portanto, é: qual registrador? A resposta vem da observação de como os desvios condicionais são usados. Eles são encontrados em loops e em instruções if, de modo que costumam desviar para uma instrução próxima. Por exemplo, cerca de metade de todos os desvios condicionais nos benchmarks SPEC vão para locais a menos de 16 instruções de distância. Como o contador de programa (PC) contém o endereço da instrução atual, podemos desviar dentro de ±215 palavras da instrução atual se usarmos o PC como o registrador a ser somado ao endereço. Quase todos os loops e as instruções if são muito menores do que 216 palavras, de modo que o PC é a opção ideal. Essa forma de endereçamento de desvio é denominada endereçamento relativo ao PC. Conforme veremos no Capítulo 4, é conveniente que o hardware incremente o PC desde cedo, a fim de que aponte para a próxima instrução. Logo, o endereço MIPS, na realidade, é relativo ao endereço da instrução
seguinte (PC + 4), em vez da instrução atual (PC). Este é outro exemplo de tornar o caso comum veloz, que neste caso significa endereçar instruções próximas.
endereçamento relativo ao PC Um regime de endereçamento em que o endereço é a soma do contador de programa (PC) e uma constante na instrução.
Como na maioria dos computadores atuais, o MIPS utiliza o endereçamento relativo ao PC para todos os desvios condicionais, pois o destino dessas instruções provavelmente estará próximo do desvio. Por outro lado, instruções de jump-and-link chamam procedimentos que não têm motivo para estarem próximos à chamada e, por isso, normalmente utilizam outras formas de endereçamento. Logo, a arquitetura MIPS oferece endereços longos para chamadas de procedimento, usando o formato do tipo J para instruções de jump e jump-and-link. Como todas as instruções MIPS possuem 4 bytes de extensão, o MIPS aumenta a distância do desvio fazendo com que o endereçamento relativo ao PC se refira ao número de palavras até a próxima instrução, no lugar do número de bytes. Assim, o campo de 16 bits pode se desviar para uma distância quatro vezes maior, interpretando o campo como um endereço relativo à palavra, e não um endereço relativo a byte. De modo semelhante, o campo de 26 bits nas instruções de jump também é um endereço de palavra, significando que representa um endereço de byte com 28 bits.
Detalhamento Como o contador de programa (PC) utiliza 32 bits, 4 bits precisam vir de outro lugar para os jumps. A instrução de jump do MIPS substitui apenas os 28 bits menos significativos do PC, deixando os 4 bits mais significativos inalterados. O loader e o link-editor (Seção 2.12) precisam ter cuidado para evitar colocar um programa entre um limite de endereços de 256MB (64 milhões de instruções); caso contrário, um jump precisa ser substituído por uma instrução de jump register precedida por outras instruções, a fim de carregar o endereço de 32 bits inteiro em um registrador.
Mostrando o offset do desvio em linguagem de máquina Exemplo O loop while da Seção 2.7 foi compilado para este código em assembly do MIPS:
Se consideramos que o loop inicia na posição 80000 da memória, qual é o código de máquina do MIPS para esse loop?
Resposta As instruções montadas e seus endereços são:
Lembre-se de que as instruções do MIPS possuem endereços em bytes, de modo que os endereços das palavras sequenciais diferem em 4, a quantidade de bytes em uma palavra. A instrução bne na quarta linha acrescenta 2 palavras ou 8 bytes ao endereço da instrução seguinte (80016), especificando o destino do desvio em relação à instrução seguinte (8 + 80016), e não em relação à instrução de desvio (12 + 80012) ou ao uso do endereço de destino completo (80024). A instrução de salto na última linha utiliza o endereço completo (20000 × 4 = 80000), correspondente ao rótulo Loop.
Interface hardware/software A maioria dos desvios condicionais é feita para um local nas proximidades, mas, ocasionalmente, eles se desviam para um ponto mais distante do que pode ser representado nos 16 bits da instrução de desvio condicional. O montador vem ao auxílio como fez com endereços ou constantes grandes: ele insere um jump incondicional para o destino do desvio, e inverte a condição de modo que o desvio decida se irá pular o jump.
Desviando para um lugar mais distante Exemplo Dado um desvio em que o registrador $s0 é igual ao registrador $s1,
substitua-o por um par de instruções que ofereça uma distância de desvio
muito maior.
Resposta Estas instruções substituem o desvio condicional com endereço curto:
Resumo dos modos de endereçamento no MIPS As diversas formas de endereçamento geralmente são denominadas modos de endereçamento. A Figura 2.18 mostra como os operandos são identificados para cada modo de endereçamento. Os modos de endereçamento do MIPS são os seguintes:
FIGURA 2.18 Ilustração dos cinco modos de endereçamento do MIPS. Os operandos estão sombreados na figura. O operando do modo 3 está na memória, enquanto o operando para o modo 2 é um registrador. Observe que as versões de load e store acessam bytes, halfwords ou palavras. Para o modo 1, o operando é formado pelos 16 bits da própria instrução. Os modos 4 e 5 endereçam as instruções na memória, com o modo 4
adicionando um endereço de 16 bits deslocado à esquerda em 2 bits ao PC, e o modo 5 concatenando um endereço de 26 bits deslocado à esquerda em 2 bits com os 4 bits superiores do PC. Observe que uma única operação pode usar mais de um modo de endereçamento. Add, por exemplo, utiliza um endereçamento imediato (addi) e por registrador (add).
1. Endereçamento imediato, em que o operando é uma constante dentro da própria instrução. 2. Endereçamento em registrador, no qual o operando é um registrador. 3. Endereçamento de base ou deslocamento, em que o operando está no local de memória cujo endereço é a soma de um registrador e uma constante na instrução. 4. Endereçamento relativo ao PC, no qual o endereço de desvio é a soma do PC e uma constante na instrução. 5. Endereçamento pseudodireto, em que o endereço de jump são os 26 bits da instrução concatenados com os bits mais altos do PC.
modo de endereçamento Um dos diversos regimes de endereçamento delimitados por seu uso variado de operandos e/ou endereços.
Interface hardware/software Embora tenhamos mostrado a arquitetura MIPS como tendo endereços de 32 bits, quase todos os microprocessadores (incluindo o MIPS) possuem extensões de endereço de 64 bits (Seção 2.17. Essas extensões foram a resposta às necessidades de software para programas maiores. O processo de extensão do conjunto de instruções permite que as arquiteturas se expandam de modo que o software possa prosseguir de forma compatível para a próxima geração da arquitetura.
Decodificando a linguagem de máquina Às vezes, você é forçado a usar engenharia reversa na linguagem de máquina para criar o código assembly original. Um exemplo é quando se examina um “dump de memória”. A Figura 2.19 mostra a codificação dos campos para a linguagem de máquina do MIPS. Essa figura ajuda na tradução manual entre o
assembly e a linguagem de máquina.
FIGURA 2.19 Codificação de instruções do MIPS. Essa notação indica o valor de um campo por linha e por coluna. Por exemplo, a parte superior da figura mostra load word na linha número 4 (100bin para os bits 31-29 da instrução) e a coluna número 3 (011bin para os bits 28-26 da instrução), de modo que o valor correspondente do campo op (bits 31-26) é 100011bin. Formato R na linha 0 e coluna 0 (op = 000000bin) é definido na parte inferior da figura. Logo, subtract na linha 4 e coluna 2 da seção inferior significa que o campo funct (bits 5-0) da instrução é 100010bin e o campo op (bits 31-26) é 000000bin.
O valor do floating point na linha 2, coluna 1, é definido na Figura 3.18, no Capítulo 3. Bltz/gez é o opcode para quatro instruções encontradas no Apêndice A: bltz, bgez, bltzal e bgezal. Este capítulo descreve as instruções indicadas com nome completo usando destaque, enquanto o Capítulo 3 descreve as instruções indicadas em mnemônicos do mesmo jeito. O Apêndice A abrange todas as instruções.
Decodificando a linguagem de máquina Exemplo Qual é a instrução em assembly correspondente a esta instrução de máquina?
Resposta O primeiro passo na conversão de hexadecimal para binário é encontrar os campos op:
Examinamos o campo op para determinar a operação. Consultando a Figura 2.19, quando os bits 31-29 são 000 e os bits 28-26 são 000, essa é uma instrução no Formato R. Vamos reformatar a instrução binária para campos no Formato R, listado na Figura 2.20:
FIGURA 2.20 Formatos das instruções do MIPS.
A parte inferior da Figura 2.19 determina a operação de uma instrução no Formato R. Nesse caso, os bits 5-3 são 100 e os bits 2-0 são 000, o que significa que esse padrão binário representa uma instrução add. Decodificamos as demais instruções examinando os valores de campo. Os valores decimais são 5 para o campo rs, 15 para rt, 16 para rd (shamt não é usado). A Figura 2.14 diz que esses números representam os registradores $a1, $a7 e $s0. Agora, podemos mostrar a instrução assembly:
A Figura 2.20 mostra todos os formatos de instrução do MIPS. A Figura 2.1 mostrou a linguagem assembly do MIPS revelada neste capítulo; a outra parte ainda oculta das instruções MIPS trata principalmente de aritmética e números reais, que serão abordados no próximo capítulo.
Verifique você mesmo I. Qual é o intervalo de endereços para desvios condicionais no MIPS (K = 1024)? 1. Endereços entre 0 e 64K – 1 2. Endereços entre 0 e 256K – 1 3. Endereços desde cerca de 32K antes do desvio até cerca de 32K depois 4. Endereços desde cerca de 128K antes do desvio até cerca de 128K depois II. Qual é o intervalo de endereços para jump e jump and link no MIPS (M = 1024K)? 1. Endereços entre 0 e 64M – 1 2. Endereços entre 0 e 256M – 1 3. Endereços desde cerca de 32M antes do desvio até cerca de 32M
depois 4. Endereços desde cerca de 128M antes do desvio até cerca de 128M depois 5. Qualquer lugar dentro de um bloco de 64M endereços, em que o PC fornece os 6 bits mais altos 6. Qualquer lugar dentro de um bloco de 256M endereços, em que o PC fornece os 4 bits mais altos III. Qual é a instrução em assembly do MIPS correspondente à instrução de máquina com o valor 0000 0000hexa? 1. j 2. Formato R 3. addi 4. sll 5. mfc0 6. Opcode indefinido: não existe uma instrução válida que corresponda a 0.
2.11. Paralelismo e instruções: Sincronização A execução paralela é mais fácil quando as tarefas são independentes, mas frequentemente elas precisam cooperar. A cooperação normalmente significa que algumas tarefas estão escrevendo novos valores que outras precisam ler. Para saber quando uma tarefa terminou de escrever, de modo que é seguro que outra tarefa leia, elas precisam de sincronização. Se elas não estiverem sincronizadas, haverá um perigo de data race, em que os resultados do programa podem mudar, dependendo de como os eventos ocorram.
data race Dois acessos à memória formam uma data race se eles forem de threads diferentes para o mesmo local, pelo menos um é de escrita, e eles ocorrem um após o outro. Por exemplo, lembre-se da analogia citada no Capítulo 1 dos oito repórteres escrevendo um artigo. Suponha que um repórter precise ler todas as seções anteriores antes de escrever uma conclusão. Logo, temos de saber quando os
outros repórteres terminaram suas seções, de modo que ele não se preocupe se será alterado depois disso. Ou seja, é melhor que eles sincronizem a escrita e leitura de cada seção, para que a conclusão seja coerente com o que é impresso nas seções anteriores. Na computação, os mecanismos de sincronização normalmente estão embutidos em rotinas de software em nível de usuário que contam com as instruções de sincronização fornecidas pelo hardware. Nesta seção, focalizamos a implementação das operações de sincronização lock e unlock. Lock e unlock podem ser usados facilmente para criar regiões nas quais apenas um único processador possa operar, algo chamado exclusão mútua, além de implementar mecanismos de sincronização mais complexos. A habilidade fundamental que exigimos para implementar a sincronização em um multiprocessador é um conjunto de primitivos de hardware com a capacidade de ler e modificar um local de memória atomicamente. Ou seja, nada mais pode se interpor entre a leitura e a escrita do local da memória. Sem essa capacidade, o custo da montagem de primitivos de sincronização básicos será muito alto e aumentará à medida que crescer a quantidade de processadores. Existem diversas formulações alternativas das primitivas de hardware básicas, todas oferecendo a capacidade de ler e modificar um local atomicamente, junto com algum modo de dizer se a leitura e escrita foram realizadas atomicamente. Em geral, os arquitetos não esperam que os usuários empreguem as primitivas de hardware básicas, mas em vez disso esperam que as primitivas sejam usadas pelos programadores de sistemas para montar uma biblioteca de sincronização, um processo que normalmente é complexo e intricado. Vamos começar com uma primitiva de hardware desse tipo, mostrando como ela pode ser usada para montar uma primitiva de sincronização básica. Uma operação típica para a montagem de operações de sincronização é a troca atômica ou swap atômico, que troca um valor em um registrador por um valor na memória. Para ver como usar isso a fim de montar uma primitiva de sincronização básica, suponha que queremos montar um bloqueio simples, em que o valor 0 é usado para indicar que o bloqueio está livre e 1 é usado para indicar que o bloqueio está indisponível. Um processador tenta definir o bloqueio realizando uma troca de 1, que está em um registrador, com o endereço de memória correspondendo ao bloqueio. O valor retornado da instrução de troca é 1 se algum outro processador já tiver solicitado acesso, e 0 em caso contrário. No segundo caso, o valor também é trocado para 1, impedindo que qualquer outra
troca em outro processador também recupere um 0. Por exemplo, considere dois processadores que tentam cada um realizar a troca simultaneamente; essa race é interrompida, pois exatamente um dos processadores realizará a troca primeiro, retornando 0, e o segundo processador retornará 1 quando realizar a troca. A chave para o uso da primitiva de troca para implementar a sincronização é que a operação seja atômica: a troca é indivisível, e duas trocas simultâneas serão ordenadas pelo hardware. É impossível que dois processadores tentando definir a variável de sincronização dessa maneira pensem que definiram a variável simultaneamente. A implementação de uma única operação de memória atômica apresenta alguns desafios no projeto do processador, pois requer uma leitura e uma escrita na memória em uma única instrução ininterrupta. Uma alternativa é ter um par de instruções em que a segunda instrução retorna um valor, mostrando se o par de instruções foi executado como se fosse atômico. O par de instruções é efetivamente atômico se parecer que todas as outras operações executadas por qualquer processador ocorreram antes ou depois do par. Assim, quando um par de instruções é efetivamente atômico, nenhum outro processador pode alterar o valor entre o par de instruções. No MIPS, esse par de instruções inclui um load especial, chamado load vinculado, e um store especial, chamado store condicional. Essas instruções são usadas em sequência: se o conteúdo do local de memória especificado pelo load vinculado for alterado antes que ocorra o store condicional para o mesmo endereço, então o store condicional falha. O store condicional é definido para armazenar o valor de um registrador na memória e alterar o valor desse registrador para 1 se tiver sucesso e para 0 se falhar. Como o load vinculado retorna o valor inicial, e o store condicional retorna 1 somente se tiver sucesso, a sequência a seguir implementa uma troca atômica no local de memória especificado pelo conteúdo de $s1:
Sempre que um processador intervém e modifica o valor na memória entre as instruções ll e sc, o script retorna 0 em $t0, fazendo com que a sequência de código tente novamente. Ao final dessa sequência, o conteúdo de $s4 e o local de memória especificado por $s1 foram trocados atomicamente.
Detalhamento Embora fosse apresentada para sincronização de multiprocessador, a troca atômica também é útil para o sistema operacional lidar com múltiplos processos em um único processador. A fim de garantir que nada interfira em um único processador, o store condicional também falha se o processador realizar uma troca de contexto entre as duas instruções (Capítulo 5). Uma vantagem do mecanismo de load vinculado/store condicional é que ele pode ser usado para montar outras primitivas de sincronização, como compare e swap atômicos ou fetch-and--increment atômicos, que são usados em alguns modelos de programação paralela. Estes envolvem mais instruções entre o ll e o sc. Como o store condicional falhará após outro store atraído ao endereço do load vinculado ou em qualquer exceção, deve-se ter o cuidado na escolha de quais instruções são inseridas entre as duas instruções. Em particular, somente instruções registrador-registrador podem ser permitidas com segurança; caso contrário, é possível criar situações de impasse, em que o processador nunca possa completar o sc, em consequência das faltas de páginas repetidas. Além disso, o número de instruções entre o load vinculado e o store condicional deve ser pequeno, para minimizar a probabilidade de que um evento não relacionado ou um processador concorrente faça com que o store condicional falhe com frequência.
Verifique você mesmo Quando você usará primitivas como load vinculado e store condicional? 1. Quando threads em cooperação de um programa paralelo precisarem ser sincronizados para obter um comportamento apropriado para leitura e escrita de dados compartilhados 2. Quando processos em cooperação em um processador precisarem ser sincronizados para a leitura e escrita de dados compartilhados
2.12. Traduzindo e iniciando um programa Esta seção descreve as quatro etapas para a transformação de um programa em C, armazenado em um arquivo no disco, para um programa executando em um computador. A Figura 2.21 mostra a hierarquia de tradução. Alguns sistemas combinam estas etapas para reduzir o tempo de tradução, mas elas são as quatro fases lógicas pelas quais os programas passam. Esta seção segue essa hierarquia de tradução.
FIGURA 2.21 Uma hierarquia de tradução para a linguagem C. Um programa em linguagem de alto nível é inicialmente compilado para um programa em assembly e depois montado em um módulo-objeto em linguagem de máquina. O link-editor combina vários módulos com as rotinas de biblioteca para resolver todas as referências. O loader, então, coloca o código de máquina nos locais apropriados da memória, de modo a ser executado pelo processador. Para agilizar o processo de tradução, algumas etapas são puladas ou combinadas. Alguns compiladores produzem módulos-objeto diretamente e alguns sistemas utilizam loaders com link-editores, que realizam as
duas últimas etapas. A fim de identificar o tipo de arquivo, o UNIX segue uma convenção de sufixo para os arquivos: os arquivos-fonte em C são chamados x.c, os arquivos em assembly são x.s, os arquivos-objeto são x.o, as rotinas de biblioteca link-editadas estaticamente são x.a, as rotinas de biblioteca link-editadas dinamicamente são x.so, e os arquivos executáveis, como padrão, são chamados a.out. O MS-DOS utiliza os sufixos .C, .ASM, .OBJ, .LIB, .DLL e .EXE para indicar a mesma coisa.
Compilador O compilador transforma o programa C em um programa em assembly, uma forma simbólica daquilo que a máquina entende. Os programas em linguagem de alto nível usam muito menos linhas de código do que a linguagem assembly, de modo que a produtividade do programador é muito mais alta. Em 1975, muitos sistemas operacionais e montadores foram escritos em linguagem assembly, pois as memórias eram pequenas e os compiladores eram ineficientes. O aumento de um milhão de vezes na capacidade de memória em um único chip de DRAM reduziu os problemas com tamanho de programas e os compiladores otimizadores de hoje podem produzir programas em assembly quase tão bem quanto um especialista em assembly, e às vezes ainda melhores, para programas grandes.
linguagem assembly Uma linguagem simbólica, que pode ser traduzida para o formato binário.
Montador Como a linguagem assembly é a interface com o software de nível superior, o montador (ou assembler) também pode cuidar de variações comuns das instruções em linguagem de máquina como se fossem instruções propriamente ditas. O hardware não precisa implementar essas instruções; porém, seu aparecimento em assembly simplifica a tradução e a programação. Essas instruções são conhecidas como pseudoinstruções.
pseudoinstrução Uma variação comum das instruções em assembly, normalmente tratada como se fosse uma instrução propriamente dita. Como já dissemos, o hardware do MIPS garante que o registrador $zero sempre tenha o valor 0. Ou seja, sempre que o registrador $zero é utilizado, ele fornece um 0, e o programador não pode alterar o valor do registrador $zero. O registrador $zero é usado para criar a instrução em linguagem assembly que copia o conteúdo de um registrador para outro. Assim, o montador do MIPS aceita esta instrução, embora ela não se encontre na arquitetura do MIPS:
O montador converte essa instrução em assembly para o equivalente em linguagem de máquina da seguinte instrução:
O montador do MIPS também converte blt (branch on less than) para as duas instruções slt e bne mencionadas no segundo exemplo da Seção 2.5. Outros exemplos são bgt, bge e ble. Ele também converte desvios a locais distantes para um desvio e um jump. Como já dissemos, o montador do MIPS permite que constantes de 32 bits sejam atribuídas a um registrador, apesar do limite de 16 bits das instruções imediatas. Resumindo, as pseudoinstruções dão ao MIPS um conjunto mais rico de instruções em linguagem assembly do que é implementado pelo hardware. O único custo é reservar um registrador, $at, para ser usado pelo montador. Se você tiver de escrever programas em assembly, use pseudoinstruções de modo a simplificar seu trabalho. Contudo, para entender a arquitetura do MIPS e ter certeza de que obterá o melhor desempenho, estude as instruções reais do MIPS, encontradas nas Figuras 2.1 e 2.19. Os montadores também aceitarão números em diversas bases. Além de binário e decimal, eles normalmente aceitam uma base mais sucinta do que o binário, mas que seja convertida facilmente para um padrão de bits. Montadores MIPS
utilizam hexadecimal. Esses recursos são convenientes, mas a tarefa principal de um montador é a montagem para código de máquina. O montador transforma o programa assembly em um arquivo objeto, que é uma combinação de instruções de linguagem de máquina, dados e informações necessárias a fim de colocar instruções corretamente na memória. Para produzir a versão binária de cada instrução no programa em assembly, o montador precisa determinar os endereços correspondentes a todos os rótulos. Os montadores registram os rótulos utilizados nos desvios e nas instruções de transferência de dados por meio de uma tabela de símbolos. Como você poderia esperar, a tabela contém pares de símbolo e endereço.
tabela de símbolos Uma tabela que combina nomes de rótulos aos endereços das palavras na memória ocupados pelas instruções. O arquivo-objeto para os sistemas UNIX normalmente contém seis partes distintas: ▪ O cabeçalho do arquivo objeto descreve o tamanho e a posição das outras partes do arquivo objeto. ▪ O segmento de texto contém o código na linguagem de máquina. ▪ O segmento de dados estáticos contém os dados alocados por toda a vida do programa. (O UNIX permite que os programas usem dados estáticos, que são alocados para o programa inteiro ou dados dinâmicos, que podem crescer ou diminuir conforme a necessidade do programa. Veja a Figura 2.13.) ▪ As informações de relocação identificam instruções e palavras de dados que dependem de endereços absolutos quando o programa é carregado na memória. ▪ A tabela de símbolos contém os rótulos restantes que não estão definidos, como referências externas. ▪ As informações de depuração contêm uma descrição resumida de como os módulos foram compilados, para o depurador poder associar as instruções de máquina aos arquivos-fonte em C e tornar as estruturas de dados legíveis. A próxima subseção mostra como juntar rotinas que já foram montadas, como as rotinas de biblioteca.
Link-editor
Link-editor O que apresentamos até aqui sugere que uma única mudança em uma linha de um procedimento exige a compilação e a montagem do programa inteiro. A tradução completa é um desperdício terrível de recursos computacionais. Essa repetição é um desperdício principalmente para rotinas de bibliotecas padrão, pois os programadores estariam compilando e montando rotinas que, por definição, quase nunca mudam. Uma alternativa é compilar e montar cada procedimento de forma independente, de modo que uma mudança em uma linha só exija a compilação e a montagem de um procedimento. Essa alternativa requer um novo programa de sistema, chamado link-editor ou linker, que apanha todos os programas em linguagem de máquina montados independentes e os “remenda”.
linker Também chamado link-editor, é um programa de sistema que combina programas em linguagem de máquina montados de maneira independente e traduz todos os rótulos indefinidos para um arquivo executável. Existem três etapas realizadas por um link-editor: 1. Colocar os módulos de código e dados simbolicamente na memória. 2. Determinar os endereços dos rótulos de dados e instruções. 3. Remendar as referências internas e externas. O link-editor utiliza a informação de relocação e a tabela de símbolos em cada módulo- -objeto para resolver todos os rótulos indefinidos. Essas referências ocorrem em instruções de desvio e endereços de dados, de modo que a tarefa desse programa é muito semelhante à de um editor: ele encontra os endereços antigos e os substitui pelos novos. A edição é a origem do nome “link-editor” ou linker para abreviar. O uso de um link-editor faz sentido porque é muito mais rápido remendar o código do que recompilá-lo e remontá-lo. Se todas as referências externas forem resolvidas, o link-editor em seguida determina os locais da memória que cada módulo ocupará. Lembre-se de que a Figura 2.13, na Seção 2.8, mostra a convenção do MIPS para alocação de programas e dados na memória. Como os arquivos foram montados isoladamente, o montador não poderia saber onde as instruções e os dados do módulo serão colocados em relação a outros módulos. Quando o link-editor coloca um módulo na memória, todas as referências absolutas, ou seja,
endereços de memória que não são relativos a um registrador, precisam ser relocadas a fim de refletir seu verdadeiro local. O link-editor produz um arquivo executável que pode ser executado em um computador. Normalmente, esse arquivo possui o mesmo formato de um arquivo-objeto, exceto que não contém referências não resolvidas. É possível ter arquivos parcialmente link-editados, como rotinas de biblioteca, que ainda possuem endereços não resolvidos e, portanto, resultam em arquivos-objeto.
arquivo executável Um programa funcional no formato de um arquivo-objeto, que não contém referências não resolvidas. Ele pode conter tabelas de símbolos e informações de depuração. Um “executável despido” não contém essa informação. As informações de relocação podem ser incluídas para o loader.
Link-edição de arquivos-objeto Exemplo Link-edite os dois arquivos-objeto a seguir. Mostre os endereços atualizados das primeiras instruções do arquivo executável gerado. Mostramos as instruções em assembly só para tornar o exemplo compreensível; na realidade, as instruções seriam números. Observe que, nos arquivos-objeto, destacamos os endereços e símbolos que precisam ser atualizados no processo de link-edição: as instruções que se referem a endereços dos procedimentos A e B e as instruções que se referem aos endereços das words de dados X e Y. Cabeçalho do arquivo-objeto
Segmento de texto
Segmento de dados
Informações de relocação
Nome
Procedimento A
Tamanho do texto
100hexa
Tamanho dos dados
20hexa
Endereço
Instrução
0
lw $a0, 0($gp)
4
jal 0
…
…
0
(X)
…
…
Endereço
Tipo de instrução Dependência
Informações de relocação
Endereço
Tipo de instrução Dependência
0
lw
X
4
jal
B
Rótulo
Endereço
X
–
B
–
Nome
Procedimento B
Tamanho do texto
200hexa
Tamanho dos dados
30hexa
Tabela de símbolos
Cabeçalho do arquivo-objeto
Segmento de texto
Segmento de dados
Informações de relocação
Tabela de símbolos
Endereço
Instrução
0
sw $al, 0($gp)
4
jal 0
…
…
0
(Y)
…
…
Endereço
Tipo de instrução Dependência
0
sw
Y
4
jal
A
Rótulo
Endereço
Y
–
A
–
Resposta O procedimento A precisa encontrar o endereço para a variável cujo rótulo é X, a fim de colocá-lo na instrução load e encontrar o endereço do procedimento B para colocá-lo na instrução jal. O procedimento B precisa do endereço da variável cujo rótulo é Y para a instrução store e o endereço do procedimento A para sua instrução jal. Pela Figura 2.13, sabemos que o segmento de texto começa no endereço 40 0000hexa e o segmento de dados em 1000 0000hexa. O texto do procedimento A é colocado no primeiro endereço e seus dados no segundo. O cabeçalho do arquivo-objeto para o procedimento A diz que seu texto possui 100hexa bytes e seus dados possuem 20hexa bytes, de modo que o endereço inicial para o texto do procedimento B é 40 0100hexa e seus dados começam em 1000 0020hexa. Cabeçalho do arquivo executável Tamanho do texto Tamanho dos dados
300hexa
Segmento de texto
Segmento de dados
Tamanho dos dados
50hexa
Endereço
Instrução
0040 0000hexa
lw$a0, 8000hexa($gp)
0040 0004hexa
jal 40 0100hexa
…
…
0040 0100hexa
sw$a1, 8020hexa ($gp)
0040 0104hexa
jal 40 0000hexa
…
…
Endereço 1000 0000hexa
(X )
…
…
1000 0020hexa
(Y )
…
…
A Figura 2.13 também mostra que o segmento de texto começa no endereço 40 0000hexa e o segmento de dados no 1000 0000hexa. O texto do procedimento A é colocado no primeiro endereço e seus dados no segundo. O cabeçalho do arquivo objeto para o procedimento A diz que seu texto é 100hexa bytes e seus dados 20hexa bytes, então o começo do endereço do texto do procedimento B é 40 0100hexa, e seus dados começam em 1000 0020hexa. Agora, o link-editor atualiza os campos de endereço das instruções. Ele usa o campo de tipo de instrução para saber o formato do endereço a ser editado. Temos dois tipos aqui: 1. Os jal são fáceis porque utilizam o endereçamento pseudodireto. O jal no 40 0004hexa recebe 40 0100hexa (o endereço do procedimento B) em seu campo de endereço, e o jal em 40 0104hexa recebe 40 0000hexa (o endereço do procedimento A) em seu campo de endereço. 2. Os endereços de load e store são mais difíceis, pois são relativos a um registrador de base. Este exemplo utiliza o ponteiro global como registrador de base. A Figura 2.13 mostra que $gp é inicializado com 1000 8000hexa. Para obter o endereço 1000 0000hexa (o endereço da palavra X), colocamos 8000hexa no campo de endereço da instrução lw, no endereço 40 0000hexa. De modo semelhante, colocamos 8020hexa no campo de endereço da instrução sw no endereço 40 0100hexa para obter o endereço 1000 0020hexa (o endereço da palavra Y).
Detalhamento
Detalhamento Lembre-se de que as instruções MIPS são alinhadas na palavra, de modo que jal remove os dois bits da direita para aumentar a faixa de endereços da instrução. Assim, ele usa 26 bits para criar um endereço de byte de 28 bits. Logo, o endereço real nos 26 bits inferiores da instrução jal neste exemplo é 10 0040hexa, em vez de 40 0100hexa.
Loader Agora que o arquivo executável está no disco, o sistema operacional o lê para a memória e o inicia. O loader segue estas etapas nos sistemas UNIX: 1. Lê o cabeçalho do arquivo executável para determinar o tamanho dos segmentos de texto e de dados. 2. Cria um espaço de endereçamento grande o suficiente para o texto e os dados. 3. Copia as instruções e os dados do arquivo executável para a memória. 4. Copia os parâmetros (se houver) do programa principal para a pilha. 5. Inicializa os registradores da máquina e define o stack pointer para o primeiro local livre. 6. Desvia para uma rotina de inicialização, que copia os parâmetros para os registradores de argumento e chama a rotina principal do programa. Quando a rotina principal retorna, a rotina de inicialização termina o programa com uma chamada exit do sistema.
loader Um programa de sistema que coloca o programa-objeto na memória principal, de modo que esteja pronto para ser executado. As Seções A.3 e A.4 no Apêndice A descrevem os link-editores e os loaders com mais detalhes.
Dinamically Linked Libraries (DLLs) A primeira parte desta seção descreve a técnica tradicional para a link-edição de bibliotecas antes de o programa ser executado. Embora essa técnica estática seja o modo mais rápido de chamar rotinas de biblioteca, ela possui algumas desvantagens:
▪ As rotinas de biblioteca se tornam parte do código executável. Se uma nova versão da biblioteca for lançada para reparar os erros ou dar suporte a novos dispositivos de hardware, o programa link-editado estaticamente continua usando a versão antiga. ▪ Ela carrega todas as rotinas na biblioteca que são chamadas de qualquer lugar no executável, mesmo que essas chamadas não sejam executadas. A biblioteca pode ser grande em relação ao programa; por exemplo, a biblioteca padrão da linguagem C possui 2,5MB. Essas desvantagens levaram às Dynamic Linked Libraries (DLLs), nas quais as rotinas da biblioteca não são link-editadas e carregadas até que o programa seja executado. Tanto o programa quanto as rotinas da biblioteca mantêm informações extras sobre a localização dos procedimentos não locais e seus nomes. Na versão inicial das DLLs, o loader executava um link-editor dinâmico, usando as informações extras no arquivo para descobrir as bibliotecas apropriadas e atualizar todas as referências externas.
Dynamically Linked Libraries (DLLs) Rotinas de bit que são vinculadas a um programa durante a execução. A desvantagem da versão inicial das DLLs era que elas ainda link-editavam todas as rotinas da biblioteca que poderiam ser chamadas, quando apenas algumas são chamadas durante a execução do programa. Essa observação levou à versão da link-edição de procedimento tardio das DLLs, no qual cada rotina só é link-editada depois de chamada. Como muitas inovações em nosso campo, esse truque conta com um certo nível de indireção. A Figura 2.22 mostra a técnica. Ela começa com as rotinas não locais chamando um conjunto de rotinas fictícias no final do programa, com uma entrada por rotina não local. Essas entradas fictícias contêm, cada uma, um jump indireto.
FIGURA 2.22 DLL por meio da link-edição de procedimento tardio. (a) Etapas para a primeira vez em que uma chamada é feita à rotina da DLL. (b) As etapas para encontrar a rotina, remapeá-la e link-editá-la são puladas em chamadas subsequentes. Conforme veremos no Capítulo 5, o sistema operacional pode evitar copiar a rotina desejada remapeando-a por meio do gerenciamento de memória virtual.
Na primeira vez em que a rotina da biblioteca é chamada, o programa chama a entrada fictícia e segue o jump indireto. Ele aponta para o código que coloca um número em um registrador para identificar a rotina de biblioteca desejada e depois desvia para o loader com link-editor dinâmico. O loader com link-editor encontra a rotina desejada, remapeia essa rotina e altera o endereço do desvio indireto, de modo a apontar para essa rotina. Depois, ele desvia para ela. Quando
a rotina termina, ele retorna ao local de chamada original. Depois disso, a chamada para rotina de biblioteca desvia indiretamente para a rotina, sem os desvios extras. Resumindo, as DLLs exigem espaço extra para as informações necessárias à link-edição dinâmica, mas não exigem que as bibliotecas inteiras sejam copiadas ou link-editadas. Elas realizam muito trabalho extra na primeira vez em que uma rotina é chamada, mas executam somente um desvio indireto depois disso. Observe que o retorno da biblioteca não realiza trabalho extra. O Microsoft Windows conta bastante com as DLLs dessa forma e esse também é um modo normal de executar programas nos sistemas UNIX atuais.
Iniciando um programa Java A discussão anterior captura o modelo tradicional de execução de um programa, no qual a ênfase está no tempo de execução rápido para um programa voltado a uma arquitetura específica ou mesmo para uma implementação específica dessa arquitetura. Na verdade, é possível executar programas Java da mesma forma que programas C. No entanto, o Java foi inventado com objetivos diferentes. Um deles era funcionar rapidamente e de forma segura em qualquer computador, mesmo que isso pudesse aumentar o tempo de execução. A Figura 2.23 mostra as etapas típicas de tradução e execução para os programas em Java. Em vez de compilar para assembly de um computador de destino, Java é compilado primeiro para instruções fáceis de interpretar: o conjunto de instruções do bytecode Java. Esse conjunto de instruções foi criado para ser próximo da linguagem Java, de modo que essa etapa de compilação seja trivial. Praticamente nenhuma otimização é realizada. Assim como o compilador C, o compilador Java verifica os tipos dos dados e produz a operação apropriada a cada tipo. Os programas em Java são distribuídos na versão binária desses bytecodes.
FIGURA 2.23 Uma hierarquia de tradução para Java. Um programa em Java primeiro é compilado para uma versão binária dos bytecodes Java, com todos os endereços definidos pelo compilador. O programa em Java agora está pronto para ser executado no interpretador, chamado Java Virtual Machine (JVM — máquina virtual Java). A JVM link-edita os métodos desejados na biblioteca Java enquanto o programa está sendo executado. Para conseguir melhor desempenho, a JVM pode chamar o compilador Just In Time (JIT), que compila os métodos seletivamente para a linguagem nativa da máquina em que está executando.
bytecode Java Instruções de um conjunto de instruções projetado para interpretar programas em Java. Um interpretador Java, chamado Java Virtual Machine (JVM), pode executar os bytecodes Java. Um interpretador é um programa que simula um conjunto de instruções. Não é necessária uma etapa de montagem separada, pois ou a tradução é tão simples que o compilador preenche os endereços ou a JVM os encontra durante a execução.
Java Virtual Machine (JVM) O programa que interpreta os bytecodes Java. A vantagem da interpretação é a portabilidade. A disponibilidade das
máquinas virtuais Java em software significou que muitos puderam escrever e executar programas Java pouco tempo depois que o Java foi anunciado. Hoje, as máquinas virtuais Java são encontradas em centenas de milhões de dispositivos, em tudo desde telefones celulares até navegadores da Internet. A desvantagem da interpretação é o desempenho fraco. Os avanços incríveis no desempenho dos anos 80 e 90 do século passado tornaram a interpretação viável para muitas aplicações importantes, mas um fator de atraso de 10 vezes, em comparação com os programas C compilados tradicionalmente, tornou o Java pouco atraente para algumas aplicações. A fim de preservar a portabilidade e melhorar a velocidade de execução, a fase seguinte do desenvolvimento do Java foram compiladores que traduziam enquanto o programa estava sendo executado. Esses compiladores Just In Time (JIT) normalmente traçam o perfil do programa em execução para descobrir onde estão os métodos “quentes”, e depois os compilam para o conjunto de instruções nativo em que a máquina virtual está executando. A parte compilada é salva para a próxima vez em que o programa for executado, de modo que possa ser executado mais rapidamente cada vez que for executado. Esse equilíbrio entre interpretação e compilação evolui com o tempo, de modo que os programas Java executados com frequência sofrem muito pouco com o trabalho extra da interpretação.
compilador Just In Time (JIT) O nome normalmente dado a um compilador que opera durante a execução, traduzindo os segmentos de código interpretados para o código nativo do computador. À medida que os computadores ficam mais rápidos, de modo que os compiladores possam fazer mais, e os pesquisadores inventam meios melhores de compilar Java durante a execução, a lacuna de desempenho entre Java e C ou C + + está se fechando.
Verifique você mesmo Qual das vantagens de um interpretador em relação a um tradutor você acredita que tenha sido mais importante para os criadores do Java? 1. Facilidade de escrita de um interpretador 2. Melhores mensagens de erro
3. Código-objeto menor 4. Independência de máquina
2.13. Um exemplo de ordenação em C para juntar tudo isso Um perigo de mostrar o código em assembly em partes é que você não terá ideia de como se parece um programa inteiro em assembly. Nesta seção, deduzimos o código do MIPS a partir de dois procedimentos escritos em C: um para trocar elementos do array e outro para ordená-los.
O procedimento swap Vamos começar com o código para o procedimento swap na Figura 2.24. Esse procedimento simplesmente troca os conteúdos de duas posições de memória. Ao traduzir de C para assembly manualmente, seguimos estas etapas gerais: 1. Alocar registradores a variáveis do programa. 2. Produzir código para o corpo do procedimento. 3. Preservar registradores durante a chamada do procedimento.
FIGURA 2.24 Um procedimento em C que troca o conteúdo de duas posições de memória.
Esta subseção utiliza esse procedimento em um exemplo de ordenação.
Esta seção descreve o procedimento swap nessas três partes, concluindo com a junção de todas as partes.
De registradores para swap Como mencionamos anteriormente na Seção 2.8, a convenção do MIPS sobre passagem de parâmetros é usar os registradores $a0, $a1, $a2 e $a3. Como swap tem apenas dois parâmetros, v e k, eles serão encontrados nos registradores $a0 e $a1. A única outra variável é temp, que associamos com o registrador $t0, pois swap é um procedimento folha (Seção “Procedimentos aninhados”). Essa alocação de registradores corresponde às declarações de variável na primeira parte do procedimento swap da Figura 2.24.
Código do corpo do procedimento swap As linhas restantes do código em C do swap são
Lembre-se de que o endereço de memória para o MIPS refere-se ao endereço em bytes, e, por isso, as words, na realidade, estão afastadas por 4 bytes. Logo, precisamos multiplicar o índice k por 4 antes de somá-lo ao endereço. Esquecer que os endereços de palavras sequenciais diferem em 4, em vez de 1, é um erro comum na programação em assembly. Logo, o primeiro passo é obter o endereço de v[k] multiplicando k por 4 por meio de um deslocamento à esquerda por 2:
Agora, lemos v[k] para $t1, e depois v[k + 1] somando 4 a $t1:
Agora, armazenamos $t0 e $t2 nos endereços trocados:
Até agora, alocamos registradores e escrevemos o código de modo a realizar as operações do procedimento. O que está faltando é o código para preservar os registradores salvos usados dentro do swap. Como não estamos usando registradores salvos nesse procedimento folha, não há nada para preservar.
O procedimento swap completo Agora, estamos prontos para a rotina inteira, que inclui o rótulo do procedimento e o jump de retorno. A fim de facilitar o acompanhamento, identificamos na Figura 2.25 cada bloco de código com sua finalidade no procedimento.
FIGURA 2.25 Código assembly do MIPS do procedimento swap na Figura 2.24.
O procedimento sort Para garantir que você apreciará o rigor da programação em assembly, vamos experimentar um segundo exemplo, maior. Nesse caso, montaremos uma rotina que chama o procedimento swap. Esse programa ordena um array de inteiros, usando ordenação por bolha ou trocas, que é uma das mais simples, mas não a mais rápida. A Figura 2.26 mostra a versão em C do programa. Mais uma vez, apresentamos esse procedimento em várias etapas, concluindo com o procedimento completo.
FIGURA 2.26 Um procedimento em C que realiza uma ordenação no array V.
Alocação de registradores para sort
Os dois parâmetros do procedimento sort, v e n, estão nos registradores de parâmetro $a0 e $a1, e alocamos o registrador $s0 a i e o registrador $s1 a j.
Código para o corpo do procedimento sort O corpo do procedimento consiste em dois loops for aninhados e uma chamada a swap que inclui parâmetros. Vamos desvendar o código de fora para o meio. O primeiro passo de tradução é o primeiro loop for:
Lembre-se de que a instrução for em C possui três partes: inicialização, teste de loop e incremento da iteração. É necessário apenas uma instrução para inicializar i como 0, a primeira parte da instrução for:
(Lembre-se de que move é uma pseudoinstrução fornecida pelo montador para a conveniência do programador em assembly; ver seção “Montador”, anteriormente, neste capítulo.) Também é necessária apenas uma instrução para incrementar i, a última parte da instrução for:
O loop deverá terminar se i < n não for verdadeiro ou, em outras palavras, deverá terminar se i ≥ n. A instrução set on less than atribui 1 ao registrador $t0 para 1 se $s0 < $a1; caso contrário, ele é 0. Como queremos testar se $s0 ≥ $a1, desviamos se o registrador $t0 for zero. Esse teste utiliza duas instruções:
O final do loop só retorna para o teste do loop:
O código da estrutura do primeiro loop for é, então,
Voilà! (Os exercícios exploram a escrita de código mais rápido para loops semelhantes.) O segundo loop for se parece com o seguinte em C:
A parte de inicialização desse loop novamente é uma instrução:
O decremento de j no final do loop também tem uma instrução:
O teste do loop possui duas partes. Saímos do loop se a condição falhar, de modo que o primeiro teste precisa terminar o loop se falhar (j < 0):
Esse desvio pulará o segundo teste de condição. Se não pular, então j ≥ 0. O segundo teste termina se V[j] > v[j + 1] não for verdadeiro, ou seja, termina se v[j]≤v[j + 1]. Primeiro, criamos o endereço multiplicando j por 4 (pois precisamos de um endereço em bytes) e somamos ao endereço base de v:
Agora, lemos o conteúdo de v[j]:
Como sabemos que o segundo elemento é exatamente a palavra seguinte, somamos 4 ao endereço no registrador $t2 para obter v[j + 1]:
O teste de v[j] ≤ v[j + 1] é o mesmo que v[j + 1] ≥ v[j], de modo que as duas instruções do teste de saída são
O final do loop retorna para o teste do loop interno:
Combinando as partes, a estrutura do segundo loop for se parece com o seguinte:
A chamada de procedimento em sort A próxima etapa é o corpo do segundo loop for:
Chamar swap é muito fácil:
Passando parâmetros em sort O problema vem quando queremos passar parâmetros, porque o procedimento sort precisa dos valores nos registradores $a0 e $a1, enquanto o procedimento swap precisa que seus parâmetros sejam colocados nesses mesmos registradores. Uma solução é copiar os parâmetros para sort em outros registradores antes do procedimento, deixando os registradores $a0 e $a1 disponíveis para a chamada de swap. (Essa cópia é mais rápida do que salvar e restaurar na pilha.) Primeiro, copiamos $a0 e $a1 para $s2 e $s3 durante o procedimento:
Depois, passamos os parâmetros para swap com estas duas instruções:
Preservando registradores em sort O único código restante é o salvamento e a restauração dos registradores. Com certeza, temos de salvar o endereço de retorno no registrador $ra, pois sort é um procedimento que foi chamado por outro procedimento. O procedimento sort também utiliza os registradores salvos $s0, $s1, $s2 e $s3, de modo que precisam ser salvos. O prólogo do procedimento sort, portanto, é
O final do procedimento simplesmente reverte todas essas instruções, depois acrescenta um jr para retornar.
O procedimento sort completo Agora, juntamos todas as partes na Figura 2.27, tendo o cuidado de substituir as referências aos registradores $a0 e $a1 nos loops for por referências aos registradores $s2 e $s3. Novamente para tornar o código mais fácil de acompanhar, identificamos cada bloco de código com sua finalidade no procedimento. Neste exemplo, nove linhas do procedimento sort em C tornaram-se 35 em assembly do MIPS.
FIGURA 2.27 Versão em assembly do MIPS para o procedimento sort da Figura 2.26.
Detalhamento Uma otimização que funciona com este exemplo é a utilização de procedimentos inline. Em vez de passar argumentos em parâmetros e invocar o código com uma instrução jal, o compilador copiaria o código do corpo do procedimento swap onde a chamada para swap aparece no código. Essa otimização evitaria quatro instruções neste exemplo. A desvantagem da otimização que utiliza procedimentos inline é que o código compilado seria maior se o procedimento inline fosse chamado de vários locais. Essa expansão de código poderia ter um desempenho inferior se aumentasse a taxa de falhas
na cache; ver Capítulo 5.
Entendendo o desempenho dos programas A Figura 2.28 mostra o impacto da otimização do compilador sobre o desempenho do programa de ordenação, tempo de compilação, ciclos de clock, contagem de instruções e CPI. Observe que o código não otimizado tem o melhor CPI, e a otimização O1 tem a menor contagem de instruções, porém O3 é a mais rápida, lembrando que o tempo é a única medida precisa do desempenho do programa.
FIGURA 2.28 Comparando desempenho, contagem de instruções e CPI usando otimizações do compilador para o Bubble Sort. Os programas ordenaram 100.000 words com o array inicializado com valores aleatórios. Estes programas foram executados em um Pentium 4 com clock de 3,06GHz e um barramento de 533MHz com 2GB de memória SDRAM DDR PC2100. Ele usava o Linux versão 2.4.20.
A Figura 2.29 compara o impacto das linguagens de programação, compilação versus interpretação, e os algoritmos sobre o desempenho das ordenações. A quarta coluna mostra que o programa C otimizado é 8,3 vezes mais rápido do que o código Java interpretado para o Bubble Sort. O uso do compilador JIT torna o programa em Java 2,1 vezes mais rápido do que o programa em C não otimizado e dentro de um fator de 1,13 mais rápido do que o código C mais otimizado. As razões não são tão próximas para o Quicksort na coluna 5, possivelmente porque é mais difícil amortizar o custo da compilação em runtime pelo tempo de execução mais curto. A última coluna demonstra o impacto de um algoritmo melhor, oferecendo um aumento no desempenho de três ordens de grandeza quando são ordenados 100.000 itens. Mesmo comparando o programa Java interpretado na coluna 5 com o
programa C compilado com as melhores otimizações na coluna 4, o Quicksort vence o Bubble Sort por um fator de 50 (0,05 x 2468 ou 123 vezes mais rápido que o código C não otimizado versus 2,41).
FIGURA 2.29 Desempenho de dois algoritmos de ordenação em C e Java usando interpretação e compiladores otimizadores em relação à versão C não otimizada. A última coluna mostra a vantagem no desempenho do Quicksort em relação ao Bubble Sort para cada linguagem e opção de execução. Esses programas foram executados no mesmo sistema da Figura 2.28. A JVM é a versão 1.3.1, e o JIT é o Hotspot versão 1.3.1, ambos da Sun.
Detalhamento Os compiladores MIPS sempre reservam espaço na pilha para os argumentos, caso precisem ser armazenados, de modo que, na realidade, eles sempre decrementam o $sp de 16, de modo a dar espaço para todos os quatro registradores de argumento (16 bytes). Um motivo é que a linguagem C oferece vararg que permite que um ponteiro recolha, digamos, o terceiro argumento de um procedimento. Quando, o compilador encontra o raro vararg, ele copia os quatro registradores de argumento para os quatro locais reservados na pilha.
2.14. Arrays versus ponteiros Um tópico desafiador para qualquer programador C novo é entender os ponteiros. A comparação entre o código assembly que usa arrays e índices de array para o código assembly que usa ponteiros fornece esclarecimentos sobre ponteiros. Esta seção mostra as versões C e assembly do MIPS de dois procedimentos para zerar (clear) uma sequência de palavras na memória: uma
usando índices de array e uma usando ponteiros. A Figura 2.30 mostra os dois procedimentos em C.
FIGURA 2.30 Dois procedimentos em C para definir um array com todos os valores iguais a zero. Clear1 usa índices, enquanto clear2 usa ponteiros. O segundo procedimento precisa de alguma explicação para os que não estão acostumados com C. O endereço de uma variável é indicado por &, e a referência ao objeto apontando por um ponteiro é indicada por *. As declarações indicam que array e p são ponteiros para inteiros. A primeira parte do loop for em clear2 atribui o endereço do primeiro elemento do array ao ponteiro p. A segunda parte do loop for testa se o ponteiro está apontando além do último elemento do array. Incrementar um ponteiro em um, na última parte do loop for, significa mover o ponteiro para o próximo objeto sequencial do seu tamanho declarado. Como p é um ponteiro para inteiros, o compilador gerará instruções MIPS para incrementar p de quatro, o número de bytes de um inteiro MIPS. A atribuição no loop coloca 0 no objeto apontado por p.
A finalidade desta seção é mostrar como os ponteiros são mapeados em instruções MIPS, e não endossar um estilo de programação ultrapassado. Ao
final da seção, veremos o impacto das otimizações do compilador moderno sobre esses dois procedimentos.
Versão de clear usando arrays Vamos começar com a versão que usa arrays, clear1, focalizando o corpo do loop e ignorando o código de ligação do procedimento. Consideramos que os dois parâmetros array e size são encontrados nos registradores $a0 e $a1, e que i é alocado ao registrador $t0. A inicialização de i, a primeira parte do loop for, é simples:
Para definir array[i] como 0, temos primeiro de obter seu endereço. Comece multiplicando i por 4, para obter o endereço em bytes:
Como o endereço inicial do array está em um registrador, temos de somá-lo ao índice para obter o endereço de array[i] usando uma instrução add:
Finalmente, podemos armazenar 0 nesse endereço:
Essa instrução é o final do corpo do loop, de modo que o próximo passo é incrementar i:
O teste do loop verifica se i é menor do que size:
Agora, já vimos todas as partes do procedimento. Aqui está o código MIPS para zerar um array usando índices:
(Esse código funciona desde que size seja maior que 0; o ANSI C requer um teste de tamanho antes do loop, mas pularemos essa conformidade aqui.)
Versão de clear usando ponteiros O segundo procedimento que usa ponteiros aloca os dois parâmetros array e size aos registradores $a0 e $a1 e aloca p ao registrador $t0. O código para o segundo procedimento começa com a atribuição do ponteiro p ao endereço do primeiro elemento do array:
O código seguinte é o corpo do loop for, que simplesmente armazena 0 em p:
Essa instrução implementa o corpo do loop, de modo que o próximo código é o incremento da iteração, que muda p de modo que aponte para a próxima palavra:
Incrementar um ponteiro em 1 significa mover o ponteiro para o próximo objeto sequencial em C. Como p é um ponteiro para inteiros, cada um usando 4 bytes, o compilador incrementa p de 4. O teste do loop vem em seguida. O primeiro passo é calcular o endereço do último elemento de array. Comece multiplicando size por 4 para obter seu endereço em bytes:
e depois acrescentamos o produto ao endereço inicial do array para obter o endereço da primeira word após o array:
O teste do loop é simplesmente para ver se p é menor do que o último elemento de array:
Com todas essas partes completadas, podemos mostrar uma versão do código para zerar um array usando ponteiros:
Como no primeiro exemplo, esse código considera que size é maior do que 0. Observe que esse programa calcula o endereço do final do array em cada iteração do loop, embora não mude. Uma versão mais rápida do código move esse cálculo para fora do loop:
Comparando as duas versões de clear
A comparação das duas sequências lado a lado ilustra a diferença entre os índices de array e ponteiros (as mudanças introduzidas pela versão de ponteiro estão destacadas):
A versão da esquerda precisa ter a “multiplicação” e a soma dentro do loop, porque i é incrementado e cada endereço precisa ser recalculado a partir do novo índice. A versão usando ponteiros para a memória, à direita, incrementa o ponteiro p diretamente. A versão usando ponteiros move o deslocamento em escala e a adição do limite de array para fora do loop, reduzindo assim as instruções executadas por iteração de 6 para 4. Essas otimizações manuais correspondem a otimizações do compilador, chamadas redução de força (deslocamento em vez de multiplicação) e eliminação da variável de indução (eliminando cálculos de endereço de array dentro dos loops).
Detalhamento Como mencionamos, o compilador C acrescentaria um teste para garantir que size seja maior do que 0. Uma maneira seria acrescentar um desvio, imediatamente antes da primeira instrução do loop, para a instrução slt.
Entendendo o desempenho dos programas As pessoas costumavam ser ensinadas a usar ponteiros em C para conseguir mais eficiência do que era possível com os arrays: “Use ponteiros, mesmo que você não consiga entender o código”. Os compiladores com otimizações modernos podem produzir um código usando arrays tão bom quanto. A maioria dos programadores de hoje prefere que o compilador realize o
trabalho pesado.
2.15. Vida real: instruções ARMv7 (32 bits) ARM é a arquitetura de conjunto de instruções mais comum para dispositivos embutidos, com mais de nove bilhões de dispositivos em 2011 usando ARM, e o crescimento recente tem sido de 2 bilhões por ano. Preparada originalmente para a Acorn RISC Machine, mais tarde modificada para Advanced RISC Machine, ARM surgiu no mesmo ano em que o MIPS e seguiu filosofias semelhantes. A Figura 2.31 lista as semelhanças. A principal diferença é que MIPS tem mais registradores e ARM tem mais modos de endereçamento.
FIGURA 2.31 Semelhanças nos conjuntos de instruções ARM e MIPS.
Existe um núcleo semelhante dos conjuntos de instruções para instruções aritmética-lógica e de transferência de dados para o MIPS e ARM, como mostra a Figura 2.32.
FIGURA 2.32 Instruções ARM registrador-registrador e transferência de dados equivalentes ao núcleo MIPS. Os traços significam que a operação não está disponível nessa arquitetura ou não é sintetizada em poucas instruções. Se houver várias escolhas de instruções equivalentes ao núcleo MIPS, elas são separadas por vírgulas. ARM inclui deslocamentos como parte de cada instrução de operação de dados, de modo que os shift com sobrescrito 1 são apenas uma variação de uma instrução move, como lsr1. Observe que o ARM não possui instrução de divisão.
Modos de endereçamento A Figura 2.33 mostra os modos de endereçamento de dados admitidos pelo ARM. Diferente do MIPS, o ARM não reserva um registrador para conter 0. Embora o MIPS tenha apenas três modos de endereçamento de dados simples (Figura 2.18), ARM tem nove, incluindo cálculos bastante complexos. Por
exemplo, ARM tem um modo de endereçamento que pode deslocar um registrador por qualquer quantidade, somá-lo aos outros registradores a fim de formar o endereço, e depois atualizar um registrador com esse novo endereço.
FIGURA 2.33 Resumo dos modos de endereçamento de dados. ARM tem modos de endereçamento separados, registrador indireto e registrador + offset, em vez de colocar apenas 0 no deslocamento do segundo modo. Para obter um maior intervalo de endereçamento, o ARM desloca o offset à esquerda, 1 ou 2 bits se o tamanho dos dados for halfword ou uma palavra inteira.
Comparação e desvio condicional MIPS usa o conteúdo dos registradores para avaliar desvios condicionais. ARM usa os quatro bits de código de condição tradicionais armazenados na palavra de status do programa: negativo, zero, carry e overflow. Eles podem ser definidos em qualquer instrução aritmética ou lógica; diferente das arquiteturas anteriores, essa configuração é opcional em cada instrução. Uma opção explícita leva a menos problemas em uma implementação em pipeline. ARM utiliza desvios condicionais para testar os códigos de condição a fim de determinar todas as relações sem sinal e com sinal possíveis. CMP subtrai um operando do outro, e a diferença define os códigos de condição. CMN compare negative soma um operando ao outro, e a soma define os códigos de condição. TST realiza um AND lógico sobre os dois operandos para definir todos os códigos de condição menos overflow, enquanto TEQ utiliza
OR exclusivo a fim de definir os três primeiros códigos de condição. Um recurso incomum do ARM é que cada instrução tem a opção de executar condicionalmente, dependendo dos códigos de condição. Cada instrução começa com um campo de 4 bits que determina se ele atuará como uma instrução de nenhuma operação (nop) ou como uma instrução real, dependendo dos códigos de condição. Logo, os desvios condicionais são corretamente considerados como executando condicionalmente a instrução de desvio incondicional. A execução condicional permite evitar que um desvio salte sobre uma instrução isolada. É preciso menos espaço de código e tempo para apenas executar uma instrução condicionalmente. A Figura 2.34 mostra os formatos de instrução para ARM e MIPS. As principais diferenças são o campo de execução condicional de 4 bits em cada instrução e o campo de registrador menor, pois ARM tem metade do número de registradores.
FIGURA 2.34 Formatos de instrução, ARM e MIPS. As diferenças resultam de arquiteturas com 16 ou 32 registradores.
Recursos exclusivos do ARM A Figura 2.35 mostra algumas instruções aritmética-lógica não encontradas no MIPS. Por não possuir um registrador dedicado para 0, ARM tem opcodes separados para realizar algumas operações que MIPS pode fazer com $zero. Além disso, ARM tem suporte para aritmética de múltiplas palavras.
FIGURA 2.35 Instruções aritméticas/lógicas do ARM não encontradas no MIPS.
O campo imediato de 12 bits do ARM tem uma nova interpretação. Os oito bits menos significativos são estendidos em zero a um valor de 32 bits, depois girados para a direita pelo número de bits especificado nos quatro primeiros bits do campo multiplicado por dois. Uma vantagem é que esse esquema pode representar todas as potências de dois em uma palavra de 32 bits. Um estudo interessante seria descobrir se essa divisão realmente recolhe mais imediatos do que um campo simples de 12 bits. O deslocamento de operandos não é limitado a imediatos. O segundo registrador de todas as operações de processamento aritmético e lógico tem a opção de ser deslocado antes de ser acionado. As opções de deslocamento são shift left logical, shift right logical, shift right arithmetic e rotate right. ARM também possui instruções para salvar grupos de registradores, chamados loads e stores em bloco. Sob o controle de uma máscara de 16 bits dentro das instruções, qualquer um dos 16 registradores pode ser carregado ou armazenado na memória em uma única instrução. Essas instruções podem salvar e restaurar registradores na entrada e retorno do procedimento. Elas também podem ser usadas para cópia de memória em bloco, sendo este o uso mais importante dessa instrução hoje em dia.
2.16. Vida real: instruções x86 A beleza está toda nos olhos de quem vê.
Margaret Wolfe Hungerford, Molly Bawn, 1877
Os projetistas de conjuntos de instruções às vezes oferecem operações mais poderosas do que aquelas encontradas no ARM e MIPS. O objetivo geralmente é reduzir o número de instruções executadas por um programa. O perigo é que essa redução pode ocorrer ao custo da simplicidade, aumentando o tempo que um programa leva para executar, pois as instruções são mais lentas. Essa lentidão pode ser o resultado de um tempo de ciclo de clock mais lento ou a requisição de mais ciclos de clock do que uma sequência mais simples. O caminho em direção à complexidade da operação é, portanto, repleto de perigos. A Seção 2.18 demonstra as armadilhas da complexidade.
A evolução do Intel x86 O ARM e o MIPS foram a visão de pequenos grupos individuais, no ano de 1985; as partes dessas arquiteturas se encaixam muito bem e a arquitetura inteira pode ser descrita de forma sucinta. Isso não acontece com o X86; ele é o produto de vários grupos independentes, que evoluíram a arquitetura por 35 anos, acrescentando novos recursos ao conjunto de instruções original, como alguém acrescentando roupas em uma mala pronta. Aqui estão os marcos importantes do X86: ▪ 1978: a arquitetura Intel 8086 foi anunciada como uma extensão compatível com o assembly para o então bem-sucedido Intel 8080, um microprocessador de 8 bits. O 8086 é uma arquitetura de 16 bits, com todos os registradores internos com 16 bits de largura. Ao contrário do MIPS, os registradores possuem usos dedicados, e, por isso, o 8086 não é considerado uma arquitetura com registradores de uso geral.
registradores de uso geral (GPR — GeneralPurpose Register) Um registrador que pode ser usado para endereços ou para dados, com praticamente qualquer instrução. ▪ 1980: o coprocessador de ponto flutuante Intel 8087 foi anunciado. Essa arquitetura estende o 8086 com cerca de 60 instruções de ponto flutuante. Em vez de usar registradores, ele conta com uma pilha (Seção 3.7). ▪ 1982: o 80286 estendeu a arquitetura 8086, aumentando o espaço de
endereçamento para 24 bits, criando um modelo de mapeamento e proteção de memória elaborado (Capítulo 5) e acrescentando algumas instruções para preencher o conjunto de instruções e manipular o modelo de proteção. ▪ 1985: o 80386 estendeu a arquitetura 80286 para 32 bits. Além de uma arquitetura de 32 bits com registradores de 32 bits e os mesmos 32 bits de espaço de endereçamento, o 80386 acrescentou novos modos de endereçamento e operações adicionais. As instruções adicionais tornam o 80386 quase uma máquina de uso geral. O 80386 também acrescentou suporte para paginação além de endereçamento segmentado (Capítulo 5). Assim como o 80286, o 80386 possui um modo para executar programas do 8086 sem mudanças. ▪ 1989-1995: os posteriores 80486 em 1989, Pentium em 1992 e Pentium Pro em 1995 visaram a um desempenho maior, com apenas quatro instruções acrescentadas ao conjunto de instruções visíveis ao usuário: três para ajudar com o multiprocessamento (Capítulo 6) e uma instrução move condicional. ▪ 1997: depois que o Pentium e o Pentium Pro estavam sendo vendidos, a Intel anunciou que expandiria as arquiteturas Pentium e Pentium Pro com as Multi Media Extensions (MMX). Esse novo conjunto de 57 instruções utiliza a pilha de ponto flutuante de modo a acelerar aplicações de multimídia e comunicações. As instruções MMX normalmente operam sobre vários elementos de dados curtos de uma só vez, na tradição das arquiteturas de única instrução e múltiplos dados (SIMD — Single Instruction, Multiple Data) (Capítulo 6). O Pentium II não introduziu novas instruções. ▪ 1999: a Intel acrescentou outras 70 instruções, denominadas Streaming SIMD Extensions (SSE), como parte do Pentium III. As principais mudanças foram incluir oito registradores separados, dobrar sua largura para 128 bits e incluir um tipo de dados de ponto flutuante com precisão simples. Logo, quatro operações de ponto flutuante de 32 bits podem ser realizadas em paralelo. Para melhorar o desempenho da memória, as SSE incluem instruções de prefetch (pré-busca) da cache, mais instruções de armazenamento de streaming, que contornam as caches e escrevem diretamente na memória. ▪ 2001: a Intel acrescentou ainda outras 144 instruções, dessa vez denominadas SSE2. O novo tipo de dados tem aritmética de precisão dupla, o que permite pares de operações de ponto flutuante de 64 bits em paralelo. Quase todas essas 144 instruções são versões de instruções MMX e SSE existentes que operam sobre 64 bits de dados em paralelo. Essa mudança não apenas habilita mais operações de multimídia, mas dá ao compilador um alvo
diferente para operações de ponto flutuante do que a arquitetura de pilha única. Os compiladores podem decidir usar os oito registradores SSE como registradores de ponto flutuante, como aqueles encontrados em outros computadores. Essa mudança aumentou o desempenho de ponto flutuante no Pentium 4, o primeiro microprocessador a incluir instruções SSE2. ▪ 2003: dessa vez, foi outra empresa, e não a Intel, que melhorou a arquitetura x86. A AMD anunciou um conjunto de extensões arquitetônicas para aumentar o espaço de endereçamento de 32 para 64 bits. Semelhante à transição do espaço de endereçamento de 16 para 32 bits em 1985, com o 80386, o AMD64 alarga todos os registradores para 64 bits. Ele também aumenta a quantidade de registradores para 16 e aumenta o número de registradores SSE de 128 bits para 16. A principal mudança na arquitetura vem da inclusão de um novo modo, chamado modo longo, que redefine a execução de todas as instruções x86 com endereços e dados de 64 bits. Para enfrentar a quantidade maior de registradores, ela acrescenta um novo prefixo às instruções. Dependendo de como você conta, o modo longo também acrescenta de 4 a 10 novas instruções e perde 27 antigas. O endereçamento de dados relativo ao PC é outra extensão. O AMD64 ainda possui um modo idêntico ao x86 (modo legado) e mais um modo que restringe os programas do usuário ao x86, mas permite que os sistemas operacionais utilizem o AMD64 (modo de compatibilidade). Esses modos permitem uma transição mais controlada para o endereçamento de 64 bits do que a arquitetura IA-64 da HP/Intel. ▪ 2004: a Intel se rende e abraça o AMD64, trocando seu nome para Extended Memory 64 Technology (EM64T). A principal diferença é que a Intel acrescentou uma instrução de comparação e troca atômica de 128 bits, que provavelmente deveria ter sido incluída no AMD64. Ao mesmo tempo, a Intel anunciou outra geração de extensões de mídia. O SSE3 acrescenta 13 instruções para dar suporte à aritmética complexa, operações gráficas sobre arrays de estruturas, codificação de vídeo, conversão de ponto flutuante e sincronismo de threads (Seção 2.11). A AMD ofereceu o SSE3 nos chips subsequentes e incluiu a instrução de troca atômica que estava faltando no ADM64, para manter a compatibilidade binária com a Intel. ▪ 2006: a Intel anuncia 54 novas instruções como parte das extensões do conjunto de instruções SSE4. Essas extensões realizam coisas como soma de diferenças absolutas, produtos escalares para arrays de estruturas, extensão de sinal ou zero de dados estreitos para tamanhos mais largos, contagem de
população e assim por diante. Ela também acrescentou suporte para máquinas virtuais (Capítulo 5). ▪ 2007: a AMD anuncia 170 instruções como parte das SSE5, incluindo 46 instruções do conjunto de instruções básico, que acrescenta três instruções de operando, como MIPS. ▪ 2011: a Intel lança a Advanced Vector Extension, que expande a largura de registrador das SSE de 128 para 256 bits, redefinindo, assim, cerca de 250 instruções e acrescentando 128 novas instruções. Essa história ilustra o impacto das “algemas douradas” da compatibilidade com o x86, pois a base de software existente em cada etapa era muito importante para ser colocada em risco com mudanças arquitetônicas significativas. Quaisquer que sejam as falhas artísticas do x86, lembre-se de que esse conjunto de instruções impulsionou a geração de computadores PC e ainda domina a parte da nuvem da era pós-PC. A fabricação de 350 milhões de chips x86 por ano pode parecer pequena em comparação com os 9 bilhões de chips ARMv7, mas muitas empresas adorariam controlar esse mercado. Apesar disso, esse ancestral diversificado levou a uma arquitetura difícil de explicar e impossível de amar. Preste bem atenção ao que você está para ver! Não tente ler esta seção com o cuidado que precisaria para escrever programas x86; em vez disso, o objetivo é que você tenha alguma familiaridade com os pontos fortes e fracos da arquitetura mais popular do mundo para uso em desktops. Em vez de mostrar o conjunto de instruções inteiro de 16, 32 e 64 bits, nesta seção, vamos nos concentrar no subconjunto de 32 bits originado com o 80386. Começamos nossa explicação com os registradores e os modos de endereçamento, prosseguimos para as operações com inteiros e concluímos com um exame da codificação da instrução.
Registradores e modos de endereçamento de dados x86 Os registradores do 80386 mostram a evolução do conjunto de instruções (Figura 2.36). O 80386 estendeu todos os registradores de 16 bits (exceto os registradores de segmento) para 32 bits, inserindo um E no início de seus nomes para indicar a versão de 32 bits. Vamos nos referir a eles genericamente como registradores de uso geral (ou GPRs — General-Purpose Registers). O 80386 contém apenas oito GPRs. Isso significa que os programas do MIPS podem usar
quatro vezes isso e os ARM, duas vezes.
FIGURA 2.36 O conjunto de registradores do 80386. Começando com o 80386, os oito registradores iniciais foram estendidos para 32 bits e também poderiam ser usados como registradores de uso geral.
A Figura 2.37 mostra que as instruções aritméticas, lógicas e de transferência de dados são instruções de dois operandos. Existem duas diferenças importantes aqui. As instruções aritméticas e lógicas do x86 precisam ter um operando que atue como origem e destino; o ARMv7 e o MIPS admitem registradores separados para origem e destino. Essa restrição coloca mais pressão sobre os registradores limitados, pois um registrador de origem precisa ser modificado. A
segunda diferença importante é que um dos operandos pode estar na memória. Assim, praticamente qualquer instrução pode ter um operando na memória, ao contrário do ARMv7 e do MIPS.
FIGURA 2.37 Tipos de instrução para instruções aritméticas, lógicas e de transferência de dados. O x86 permite as combinações mostradas. A única restrição é a ausência de um modo memória-memória. Os imediatos podem ser de 8, 16 ou 32 bits de extensão; um registrador é qualquer um dos 14 principais registradores da Figura 2.36 (não EIP ou EFLAGS).
Os modos de endereçamento de memória, descritos com detalhes a seguir, oferecem dois tamanhos de endereços dentro da instrução. Esses chamados deslocamentos podem ser de 8 bits ou de 32 bits. Embora um operando da memória possa usar qualquer modo de endereçamento, existem restrições em relação a quais registradores podem ser usados em um modo. A Figura 2.38 mostra os modos de endereçamento do x86 e quais GPRs não podem ser usados com cada modo, além de como obter o mesmo efeito usando instruções MIPS.
FIGURA 2.38 Modos de endereçamento de 32 bits do x86 com restrições de registrador e o código MIPS equivalente. O modo de endereçamento Base mais Índice Escalado, que não
aparece no ARM ou no MIPS, foi incluído para evitar as multiplicações por quatro (fator de escala 2) para transformar um índice de um registrador em um endereço em bytes (Figuras 2.25 e 2.27). Um fator de escala 1 é usado para dados de 16 bits e um fator de escala 3, para dados de 64 bits. O fator de escala 0 significa que o endereço não é escalado. Se o deslocamento for maior do que 16 bits no segundo ou quarto modos, então o modo MIPS equivalente precisa de mais duas instruções: um lui para ler os 16 bits mais altos do deslocamento e um add para somar a parte alta do endereço ao registrador base $s1. (A Intel oferece dois nomes diferentes para o que é chamado modo de endereçamento com Base — com Base e Indexado –, mas eles são basicamente idênticos, e os combinamos aqui.)
Operações com inteiros do x86 O 8086 oferece suporte para tipos de dados de 8 bits (byte) e 16 bits (word). O 80386 acrescenta endereços e dados de 32 bits (double words) ao x86. (AMD64 acrescenta endereços e dados de 64 bits, chamados quad words; vamos nos ater ao 80386 nesta seção.) As distinções de tipo de dados se aplicam a operações com registrador e também a acessos à memória. Quase toda operação funciona sobre dados de 8 bits e sobre um tamanho de dados maior. Esse tamanho é determinado pelo modo e é de 16 bits ou de 32 bits. Logicamente, alguns programas querem operar sobre dados de todos os três tamanhos, de modo que os arquitetos do 80386 ofereceram uma forma conveniente de especificar cada versão sem expandir muito o tamanho do código. Elas decidiram que os dados de 16 bits ou de 32 bits dominam a maioria dos programas e, por isso, faz sentido poder definir um tamanho grande padrão. Esse tamanho de dados padrão é definido por um bit no registrador do segmento de código. Para redefini-lo, um prefixo de 8 bits é anexado à instrução a fim de dizer à máquina para usar o outro tamanho grande para essa instrução. A solução do prefixo foi emprestada do 8086, o que permite que diversos prefixos modifiquem o comportamento da instrução. Os três prefixos originais redefinem o registrador de segmento padrão, bloqueiam o barramento para dar suporte à sincronização (Seção 2.11) ou repetem a instrução seguinte até o registrador ECX chegar a 0. Esse último prefixo tinha por finalidade estar emparelhado com uma instrução mover byte para mover um número variável de
bytes. O 80386 também acrescentou um prefixo para redefinir o tamanho de endereço padrão. As operações com inteiros do x86 podem ser divididas em quatro classes principais: 1. Instruções para movimentação de dados, incluindo move, push e pop 2. Instruções aritméticas e lógicas, incluindo operações aritméticas de teste, inteiros e decimais 3. Fluxo de controle, incluindo desvios condicionais, jumps incondicionais, chamadas e retornos 4. Instruções para manipulação de strings, incluindo movimento e comparação de strings As duas primeiras categorias não precisam de comentários, exceto que as operações de instruções aritméticas e lógicas permitem que o destino seja um registrador ou um local da memória. A Figura 2.39 mostra algumas instruções x86 típicas e suas funções.
FIGURA 2.39 Algumas instruções x86 típicas e suas funções. Uma lista de operações frequentes aparece na Figura 2.40. O CALL salva na pilha o EIP da próxima instrução. (EIP é o PC — Program Counter — da Intel.)
Os desvios condicionais no x86 são baseados em códigos de condição ou flags, assim como no ARMv7. Os códigos de condição são definidos como um efeito colateral de uma operação; a maioria é usada para comparar o valor de um resultado com 0. Os desvios, então, testam os códigos de condição. Os endereços de desvio relativos ao PC precisam ser especificados no número de bytes, visto
que, ao contrário do ARMv7 e MIPS, nem todas as instruções do 80386 possuem 4 bytes de extensão. As instruções para manipulação de strings fazem parte dos antepassados 8080 do x86 e não são comumente executadas na maioria dos programas. Em geral, são mais lentas do que as rotinas de software equivalentes (veja a falácia na Seção 2.17). A Figura 2.40 lista algumas das instruções do x86 com inteiros. Muitas das instruções estão disponíveis nos formatos byte e word.
FIGURA 2.40 Algumas operações típicas do x86. Muitas operações utilizam o formato registrador-memória, no qual a origem ou o destino pode ser a memória e o outro pode ser um registrador ou um operando imediato.
Codificação de instruções x86
Codificação de instruções x86 Deixando o pior para o final, a codificação de instruções no 80386 é complexa, com muitos formatos de instrução diferentes. As instruções para o 80386 podem variar de 1 byte, quando não existem operandos, até 15 bytes. A Figura 2.41 mostra o formato de instrução para várias instruções de exemplo na Figura 2.39. O byte de opcode normalmente contém um bit indicando se o operando é de 8 bits ou de 32 bits. Para algumas instruções, o opcode pode incluir o modo de endereçamento e o registrador; isso acontece em muitas instruções que possuem a forma “registrador = registrador op imediato”. Outras instruções utilizam um “pós-byte” ou byte de opcode extra, rotulado “mod, reg, r/m”, que contém a informação sobre o modo de endereçamento. Esse pós-byte é usado para muitas das instruções que endereçam a memória. O modo “base mais índice escalado” utiliza um segundo pós-byte, rotulado com “sc, índice, base”.
FIGURA 2.41 Formatos típicos de instruções x86. A Figura 2.42 mostra a codificação do pós-byte. Muitas instruções contêm o campo de 1 bit w, que indica se a operação é de um byte ou double word. O campo d em MOV é usado em instruções que podem mover de/para a memória, e mostra a direção do movimento. A instrução ADD requer 32 bits para o campo imediato, visto que no modo de 32 bits, os imediatos são de 8 bits ou de 32 bits. O campo imediato no TEST tem 32 bits de extensão, pois não existe um imediato de 8 bits para testar no modo de 32 bits. Em geral, as instruções podem variar de 1 a 15 bytes de extensão. O tamanho grande vem dos prefixos extras de 1 byte, tendo tanto um imediato de 4 bytes quanto um endereço de deslocamento de 4 bytes, usando um opcode de 2 bytes e usando o especificador do modo de índice escalado, que acrescenta outro byte.
A Figura 2.42 mostra a codificação dos dois especificadores de endereço pósbyte para os modos de 16 e 32 bits. Infelizmente, para entender quais registradores e quais modos de endereçamento estão disponíveis, você precisa ver a codificação de todos os modos de endereçamento e, às vezes, até mesmo a codificação das instruções.
FIGURA 2.42 A codificação do primeiro especificador de endereço do x86: “mod, reg, r/m”. As quatro primeiras colunas mostram a codificação do campo reg de 3 bits, que depende do bit w do opcode e se a máquina está no modo de 16 bits (8086) ou no modo de 32 bits (80386). As demais colunas explicam os campos mod e r/m. O significado do campo r/m de 3 bits depende do valor do campo mod de 2 bits e do tamanho do endereço. Basicamente, os registradores utilizados no cálculo do endereço são listados na sexta e sétima colunas, sob mod = 0, com mod = 1 acrescentando um deslocamento de 8 bits e mod = 2 acrescentando um deslocamento de 16 ou 32 bits, dependendo do modo do endereço. As exceções são: 1) r/m = 6 quando mod = 1 ou mod = 2 no modo de 16 bits seleciona BP mais o deslocamento; 2) r/m = 5 quando mod = 1 ou mod = 2 no modo 16 bits seleciona EBP mais deslocamento; e 3) r/m = 4 no modo de 32 bits quando mod não é igual a 3, em que (sib) significa o uso do modo de índice escalado, mostrado na Figura 2.38. Quando mod = 3, o campo r/m indica um registrador, usando a mesma codificação que o campo reg combinado com o bit w.
Conclusão sobre o x86 A Intel tinha um microprocessador de 16 bits dois anos antes das arquiteturas mais elegantes de seus concorrentes, como o Motorola 68000, e essa dianteira levou à seleção do 8086 como CPU para o IBM PC. Os engenheiros da Intel
geralmente reconhecem que o x86 é mais difícil de ser montado do que máquinas como ARMv7 e MIPS, mas o mercado maior significou, na era do PC, que a AMD e a Intel podiam abrir mão de mais recursos para ajudar a contornar a complexidade adicional. O que o x86 perde no estilo é compensado na fatia do mercado, tornando-o belo, do ponto de vista apropriado. O que o salva é que os componentes arquitetônicos mais usados do x86 não são tão difíceis de implementar, como a AMD e a Intel já demonstraram, melhorando rapidamente o desempenho dos programas com inteiros desde 1978. Para obter esse desempenho, os compiladores precisam evitar as partes da arquitetura difíceis de implementar com rapidez. Entretanto, na era pós-PC, apesar das habilidades consideráveis em termos de arquitetura e manufatura, o x86 ainda não se tornou competitivo no dispositivo móvel pessoal.
2.17. Vida real: instruções ARMv8 (64 bits) Dos muitos problemas em potencial em um conjunto de instruções, aquele que é quase impossível de ser contornado é ter um endereço de memória muito pequeno. Enquanto o x86 foi estendido com sucesso, primeiro para endereços de 32 bits e depois para endereços de 64 bits, muitos de seus irmãos ficaram para trás. Por exemplo, o MOStek 6502 com endereços de 16 bits controlava o Apple II, mas mesmo tendo saído na frente com o primeiro computador pessoal comercialmente bem-sucedido, sua falta de bits de endereço o condenou à caixa de lixo da história. Os arquitetos do ARM podiam ver o iminente fim de seu computador com 32 bits de endereços, e iniciaram o projeto da versão com endereços de 64 bits do ARM em 2007. Finalmente, ele foi revelado em 2013. Em vez de algumas pequenas mudanças estéticas, para que todos os registradores tivessem 64 bits de largura, basicamente o que aconteceu com o x86, o ARM fez uma reforma completa. A boa notícia é que, se você conhece o MIPS, será muito fácil entender o ARMv8, como é chamada a versão para 64 bits. Primeiro, em comparação com o MIPS, o ARM descartou praticamente todos os recursos incomuns do v7: ▪ Não há um campo de execução condicional, como havia em quase toda instrução no v7. ▪ O campo imediato é simplesmente uma constante de 12 bits, em vez de basicamente uma entrada para uma função que produz uma constante, como
no v7. ▪ O ARM retirou as instruções Load Multiple e Store Multiple. ▪ O PC não é mais um dos registradores, o que resultava em desvios inesperados se você escrevesse nele. ▪ Em segundo lugar, o ARM acrescentou recursos que faltavam e que são úteis no MIPS: ▪ O V8 possui 32 registradores de uso geral, que os escritores de compilador certamente apreciam muito. Assim como o MIPS, um registrador é fixado em 0, embora em vez disso se refira ao ponteiro de pilha em instruções load e store. ▪ Seus modos de endereçamento funcionam para todos os tamanhos de word no ARMv8, o que não acontecia no ARMv7. ▪ Ele inclui uma instrução de divisão, que foi omitida do ARMv7. ▪ Ele acrescenta o equivalente ao “branch if equal” e o “branch if not equal” do MIPS. Como a filosofia do conjunto de instruções do v8 é muito mais próxima do MIPS do que do v7, nossa conclusão é que a semelhança principal entre o ARMv7 e o ARMv8 está no nome.
2.18. Falácias e armadilhas Falácia: instruções mais poderosas significam maior desempenho. Parte do poder do Intel x86 são os prefixos que podem modificar a execução da instrução seguinte. Um prefixo pode repetir a instrução seguinte até que um contador chegue a 0. Assim, para mover dados na memória, pode parecer que a sequência de instruções natural seria usar move com o prefixo de repetição para realizar movimentações de memória para memória em 32 bits. Um método alternativo, que usa as instruções padrão encontradas em todos os computadores, é carregar os dados nos registradores e depois armazenar os registradores na memória. Essa segunda versão do programa, com o código replicado para reduzir o trabalho extra do loop, copia cerca de 1,5 vez mais rápido. Uma terceira versão, que usa os registradores de ponto flutuante maiores no lugar dos registradores inteiros do x86, copia cerca de 2,0 vezes mais rápido do que a instrução de movimentação complexa.
Falácia: escreva em assembly para obter o maior desempenho. Houve uma época em que os compiladores para as linguagens de programação produziam sequências de instrução ingênuas; a sofisticação cada vez maior dos compiladores significa que a lacuna entre o código compilado e o código produzido à mão está se fechando rapidamente. De fato, para competir com os compiladores atuais, o programador assembly precisa entender perfeitamente os conceitos dos Capítulos 4 e 5 (pipelining do processador e hierarquia de memória). Essa batalha entre compiladores e codificadores assembly é uma situação em que os humanos estão perdendo terreno. Por exemplo, a linguagem C oferece ao programador uma chance de dar uma sugestão ao compilador sobre quais variáveis manter em registradores, em vez de passar para a memória. Quando os compiladores eram fracos na alocação de registradores, essas sugestões eram vitais para o desempenho. De fato, alguns livros-texto sobre C gastavam muito tempo dando exemplos com sugestões de como usar registradores com eficiência. Os compiladores C de hoje, em geral, ignoram essas sugestões, pois o compilador realiza um trabalho melhor na alocação do que o programador. Mesmo se a escrita à mão resultasse em código mais rápido, os perigos de escrever em assembly são: maior tempo gasto codificando e depurando, perda de portabilidade e dificuldade de manter esse código. Um dos poucos axiomas aceitos de modo geral na engenharia de software é que a codificação leva mais tempo se você escrever mais linhas, e claramente é preciso mais linhas para escrever um programa em assembly do que em C ou Java. Além do mais, uma vez codificado, o próximo perigo é que ele se torne um programa popular. Esses programas sempre vivem por mais tempo do que o esperado, significando que alguém terá de atualizar o código por vários anos e fazer com que funcione com novas versões dos sistemas operacionais e novos modelos de máquinas. A escrita em linguagem de alto nível no lugar do assembly não apenas permite que os compiladores futuros ajustem o código a máquinas futuras, mas também torna o software mais fácil de manter e permite que o programa execute em mais modelos de computadores. Falácia: a importância da compatibilidade binária comercial significa que os conjuntos de instruções bem-sucedidos não mudam.
Embora a compatibilidade binária seja sacrossanta, a Figura 2.43 mostra que a arquitetura do x86 cresceu drasticamente. A média é mais de uma instrução por mês no decorrer do seu tempo de vida de 35 anos!
FIGURA 2.43 Crescimento do conjunto de instruções x86 com o tempo. Embora haja um valor técnico claro em algumas dessas extensões, essa mudança rápida também aumenta a dificuldade para outras empresas tentarem montar processadores compatíveis.
Armadilha: esquecer que os endereços sequenciais de palavras em máquinas com endereçamento em bytes não diferem em um. Muitos programadores assembly têm lutado contra erros cometidos pela suposição de que o endereço da próxima palavra pode ser encontrado incrementando-se o endereço em um registrador por um, em vez do tamanho da palavra em bytes. Prevenir é melhor do que remediar!
Armadilha: usando um ponteiro para uma variável automática fora de seu procedimento de definição. Um engano comum ao lidar com ponteiros é passar um resultado de um procedimento que inclui um ponteiro para um array que é local a esse procedimento. Seguindo a disciplina de pilha da Figura 2.12, a memória que contém o array local será reutilizada assim que o procedimento retornar. Os ponteiros para variáveis automáticas podem levar ao caos.
2.19. Comentários finais Menos significa mais. Robert Browning, Andrea del Sarto, 1855
Os dois princípios do computador com programa armazenado são o uso de instruções que sejam indiferentes de números e o uso de memória alterável para os programas. Estes princípios permitem que uma única máquina auxilie cientistas ambientais, consultores financeiros e autores de romance em suas especialidades. A seleção de um conjunto de instruções que a máquina possa entender exige um equilíbrio delicado entre a quantidade de instruções necessárias para executar um programa, a quantidade de ciclos de clock necessários por uma instrução e a velocidade do clock. Como ilustramos neste capítulo, três princípios de projeto orientam os autores de conjuntos de instruções a estabelecer esse equilíbrio delicado: 1. Simplicidade favorece a regularidade. A regularidade motiva muitos recursos do conjunto de instruções do MIPS: mantendo todas as instruções com um único tamanho, sempre exigindo três operandos de registrador nas instruções aritméticas e mantendo os campos de registrador no mesmo lugar em cada formato de instrução. 2. Menor é mais rápido. O desejo de velocidade é o motivo para que o MIPS tenha 32 registradores em vez de muito mais. 3. Um bom projeto exige bons compromissos. Um exemplo do MIPS foi o compromisso entre providenciar endereços e constantes maiores nas instruções e manter todas as instruções com o mesmo tamanho. Também vimos a grande ideia de tornar o caso comum veloz aplicada a conjuntos de instruções e também à arquitetura do computador. Alguns
exemplos de tornar o caso comum do MIPS veloz são o endereçamento relativo ao PC para desvios condicionais e o endereçamento imediato para operandos constantes maiores.
Acima desse nível de máquina está o assembly, uma linguagem que os humanos podem ler. O montador traduz isso para os números binários que as máquinas podem entender e até mesmo “estende” o conjunto de instruções, criando instruções simbólicas que não estão no hardware. Por exemplo, constantes ou endereços que são muito grandes são divididos em partes com tamanho apropriado, variações comuns de instruções recebem seu próprio nome, e assim por diante. A Figura 2.44 lista as instruções MIPS que abordamos até aqui, tanto instruções reais quanto pseudoinstruções. Ocultar os detalhes do nível mais alto é outro exemplo da grande ideia da abstração.
FIGURA 2.44 O conjunto de instruções do MIPS explicado até aqui, com as instruções MIPS reais à esquerda e as pseudoinstruções à direita. O Apêndice A (Seção A.10) descreve a arquitetura MIPS completa. A Figura 2.1 mostra mais detalhes da arquitetura MIPS revelada neste capítulo. As informações que aparecem aqui são encontradas nas colunas 1 e 2 do Guia de Referência do MIPS, no final deste livro.
Cada categoria de instruções MIPS está associada a construções que aparecem nas linguagens de programação: ▪ As instruções aritméticas correspondem às operações encontradas nas instruções de atribuição. ▪ As instruções de transferência de dados provavelmente ocorrerão quando se lida com estruturas de dados, como arrays e estruturas. ▪ Os desvios condicionais são usados em instruções if e em loops. ▪ Os jumps incondicionais são usados em chamadas de procedimento e em retornos, e para instruções case/switch. Essas instruções não nasceram iguais; a popularidade de poucas domina muitas. Por exemplo, a Figura 2.45 mostra a popularidade de cada classe de instruções para o SPE CPUC2006. A popularidade variada das instruções desempenha um papel importante nos capítulos sobre desempenho, caminho de dados, controle e pipelining.
FIGURA 2.45 Classes de instruções MIPS, exemplos, correspondência com construções de linguagem de programação de alto nível e porcentagem média de instruções do MIPS executadas por categoria para os benchmarks médios SPEC CPU2006 de inteiros e ponto flutuante. A Figura 3.26 no Capítulo 3 mostra a porcentagem das instruções MIPS individuais executadas.
Depois que explicarmos a aritmética do computador no Capítulo 3, revelaremos mais da arquitetura do conjunto de instruções do MIPS.
2.20. Exercícios O Apêndice A descreve o simulador do MIPS, que é útil para estes exercícios. Embora o simulador aceite pseudoinstruções, tente não as usar em qualquer exercício que pedir para produzir código do MIPS. Seu objetivo deverá ser aprender o conjunto de instruções MIPS real, e se você tiver de contar instruções, sua contagem deverá refletir as instruções reais executadas, e não as pseudoinstruções. Existem alguns casos em que as pseudoinstruções precisam ser usadas (por exemplo, a instrução la, quando um valor real não é conhecido durante a codificação em assembly). Em muitos casos, elas são muito convenientes e resultam em código mais legível (por exemplo, as instruções li e move). Se você decidir usar pseudoinstruções por esses motivos, por favor, acrescente uma sentença ou duas à sua solução, indicando quais pseudoinstruções usou e por quê. 2.1 [5] Para a instrução C a seguir, qual é o código assembly do MIPS correspondente? Suponha que as variáveis f, g, h e i sejam dadas e possam ser consideradas inteiros de 32 bits, conforme declarado em um programa C. Use um número mínimo de instruções assembly do MIPS.
2.2 [5] Para as instruções assembly do MIPS a seguir, qual é a instrução C correspondente?
2.3 [5] Para a instrução C a seguir, qual é o código assembly do MIPS correspondente? Suponha que as variáveis f, g, h, i e j sejam atribuídas aos registradores $s0, $s1, $s2, $s3 e $s4, respectivamente. Suponha que o endereço de base dos arrays A e B estejam nos registradores $s6 e $s7, respectivamente.
2.4 [5] Para as instruções assembly do MIPS a seguir, qual é a instrução C correspondente? Suponha que as variáveis f, g, h, i e j sejam atribuídas aos registradores $s0, $s1, $s2, $s3 e $s4, respectivamente. Suponha também que o endereço de base dos arrays A e B estejam nos registradores $s6 e $s7, respectivamente.
2.5 [5] Para as instruções assembly do MIPS no Exercício 2.4, reescreva o código assembly para diminuir o número de instruções MIPS (se possível) necessárias para executar a mesma função. 2.6 A tabela a seguir mostra valores de 32 bits de um array armazenado na memória. Endereço
Dados
24
2
38
4
32
3
36
6
40
1
2.6.1 [5] Para os locais de memória na tabela anterior, escreva o código C classificando os dados do mais baixo ao mais alto, colocando o menor valor no menor local de memória mostrado na figura. Suponha que os dados mostrados representem a variável C chamada Array, que é um array do tipo interface, e que o primeiro número no array mostrado seja o primeiro elemento no array. Suponha que essa máquina em particular seja uma máquina endereçável por byte e uma word consista em 4 bytes. 2.6.2 [5] Para os locais de memória na tabela anterior, escreva o código MIPS que classifique os dados do mais baixo ao mais alto, colocando o menor valor no menor local de memória. Use um número mínimo de instruções MIPS. Suponha que o endereço de base de Array esteja armazenado no registrador $s6. 2.7 [5] Mostre como o valor 0xabcdef12 seria arrumado na memória de uma máquina little-endian e uma máquina big-endian. Suponha que os dados sejam armazenados a partir do endereço 0. 2.8 [5] Traduza 0xabcdef12 para decimal. 2.9 [5] Traduza o código C a seguir para MIPS. Suponha que as variáveis f, g, h, I e j sejam atribuídas aos registradores $s0, $s1, $s2, $s3 e $s4, respectivamente. Suponha que o endereço de base dos arrays A e B estejam nos registradores $s6 e $s7, respectivamente. Suponha que os elementos dos arrays A e B sejam words de 4 bytes:
2.10 [5] Traduza o código MIPS a seguir para C. Suponha que as variáveis f, g, h, i e j sejam atribuídas aos registradores $s0, $s1, $s2, $s3 e $s4, respectivamente. Suponha que o endereço de base dos arrays A e B estejam nos registradores $s6 e $s7, respectivamente.
2.11 [5] Para cada instrução MIPS, mostre o valor dos campos de opcode (OP), registrador fonte (RS) e registrador de destino (RT). Para as instruções tipo I, mostre o valor do campo imediato, e para as instruções tipo R, mostre o valor do campo de registrador de destino (RD). 2.12 Suponha que os registradores $s0 e $s1 mantenham os valores 0 × 80000000 e 0 × D0000000, respectivamente. 2.12.1 [5] Qual é o valor de $t0 para o código assembly a seguir?
2.12.2 [5] O resultado em $t0 é o resultado desejado ou houve overflow? 2.12.3 [5] Para o conteúdo dos registradores $s0 e $s1, conforme especificado acima, qual é o valor de $t0 para o código assembly a seguir?
2.12.4 [5] O resultado em $t0 é o resultado desejado ou houve overflow?
2.12.5 [5] Para o conteúdo dos registradores $s0 e $s1 especificado acima, qual é o valor de $t0 para o código assembly a seguir?
2.12.6 [5] O resultado em $t0 é o resultado desejado ou houve overflow? 2.13 Suponha que $s0 contenha o valor 128dec. 2.13.1 [5] Para a instrução add $t0, $s0, $s1, qual é ou quais são as faixas de valores para $s1 que resultaria(m) em overflow? 2.13.2 [5] Para a instrução sub $t0, $s0, $s1, qual é ou quais são as faixas de valores para $s1 que resultaria(m) em overflow? 2.13.3 [5] Para a instrução sub $t0, $s1, $s0, qual é ou quais são as faixas de valores para $s1 que resultaria(m) em overflow? 2.14 [5] Forneça o tipo e a instrução em linguagem assembly para o seguinte valor binário: 0000 0010 0001 0000 1000 0000 0010 0000bin. 2.15 [5] Forneça o tipo e a representação hexadecimal da seguinte instrução: sw $t1, 32($t2) 2.16 [5] Forneça o tipo, a instrução em linguagem assembly e a representação binária da instrução descrita pelos seguintes campos MIPS:
2.17 [5] Forneça o tipo, a instrução em linguagem assembly e a representação binária da instrução descrita pelos seguintes campos MIPS:
2.18 [5] Suponha que quiséssemos expandir o arquivo de registradores MIPS para 128 registradores e expandir o conjunto de instruções para conter quatro vezes o número de instruções atuais. 2.18.1 [5] Como isso afetaria o tamanho de cada um dos campos de bit
nas instruções do tipo R? 2.18.2 [5] Como isso afetaria o tamanho de cada um dos campos de bit nas instruções do tipo I? 2.18.3 [5] Como cada uma das duas mudanças propostas diminuiria o tamanho de um programa em assembly MIPS? Por outro lado, como a mudança proposta aumentaria o tamanho de um programa em assembly MIPS? 2.19 Considere o seguinte conteúdo de registradores:
2.19.1 [5] Para os valores de registradores mostrados acima, qual é o valor de $t2 para a seguinte sequências de instruções? sll $t2, $t0, 44 ou $t2, $t2, $t1
2.19.2 [5] Para os valores de registradores mostrados acima, qual é o valor de $t2 para a seguinte sequências de instruções?
2.19.3 [5] Para os valores de registradores mostrados acima, qual é o valor de $t2 para a seguinte sequência de instruções?
2.20 [5] Ache a sequência mais curta de instruções MIPS que extraia os bits de 16 até 11 do registrador $t0 e use o valor desse campo para substituir os bits de 31 até 26 no registrador $t1 sem alterar os outros 26 bits do registrador $t1. 2.21 [5] Forneça um conjunto mínimo de instruções MIPS que possa ser utilizado para implementar a seguinte pseudoinstrução:
2.22 [5] Para a instrução C a seguir, escreva uma sequências mínima de instruções assembly do MIPS que faça a operação idêntica. Suponha que $t1 = A, $t2 = B, e $s1 seja o endereço de base de C.
2.23 [5] Suponha que $t0 contenha o valor 0x00101000. Qual é o valor de $t2 após as instruções a seguir?
2.24 [5] Suponha que o contador de programa (PC) seja definido em 0x2000 0000. É possível usar a instrução assembly do MIPS jump (j) para definir o PC para o endereço como 0x4000 0000? É possível usar a instrução assembly do MIPS branch-on-equal (beq) para definir o PC como esse mesmo endereço? 2.25 A instrução a seguir não está incluída no conjunto de instruções do MIPS:
2.25.1 [5] Se essa instrução tivesse que ser implementada no conjunto de instruções do MIPS, qual seria o formato de instrução mais apropriado? 2.25.2 [5] Qual é a sensibilidade de instruções MIPS mais curta que realiza a mesma operação? 2.26 Considere o seguinte loop em MIPS:
2.26.1 [5] Suponha que o registrador $t1 seja inicializado com o valor 10. Qual é o valor no registrador $s2, supondo que $s2 seja inicialmente zero? 2.26.2 [5] Para cada um dos loops acima, escreva a rotina equivalente em código C. Suponha que os registradores $s1, $s2, $t1 e $t2 sejam inteiros A, B, I e temp, respectivamente. 2.26.3 [5] Para os loops escritos em assembly MIPS acima, suponha que o registrador $t1 seja inicializado com o valor N. Quantas instruções MIPS são executadas? 2.27 [5] Traduza o código C para o código assembly do MIPS. Use um número mínimo de instruções. Suponha que os valores de a, b, i e j estejam nos registradores $s0, $s1, $t0, $t1, respectivamente. Além disso, suponha que o registrador $s2 mantenha o endereço de base do array D.
2.28 [5] Quantas instruções MIPS são necessárias para implementar o código C do Exercício 2.27? Se as variáveis a e b forem inicializadas como 10 e 1 e todos os elementos de D forem inicialmente 0, qual é o número total de instruções MIPS que são executadas para completar o loop? 2.29 [5] Traduza esses loops para C. Suponha que o inteiro i em nível de C seja mantido no registrador $t1, $s2 mantenha o inteiro em nível de C chamado result, e $s0 mantenha o endereço de base do inteiro MemArray.
2.30 [5] Reescreva o loop do Exercício 2.29 para reduzir o número de instruções MIPS executadas. 2.31 [5] Implemente o seguinte código C em assembly MIPS. Qual é o número total de instruções MIPS necessárias para executar a função?
2.32 [5] As funções normalmente podem ser implementadas pelos compiladores “em linha”. Uma função em linha é quando o corpo da função é copiado para o espaço do programa, permitindo que o overhead da chamada de função seja eliminado. Implemente uma versão “em linha” do código C acima em assembly do MIPS. Qual é a redução no número total de instruções assembly do MIPS necessárias para completar a função? Suponha que a variável C, n, seja inicializada como 5. 2.33 [5] Para cada chamada de função, mostre o conteúdo da pilha após
a chamada de função ser feita. Suponha que o ponteiro de pilha esteja originalmente no endereço 0x7ffffffc e siga as convenções de registrador especificadas na Figura 2.11. 2.34 Traduza a função f para a linguagem assembly do MIPS. Se você precisar usar os registradores de $t0 até $t7, use primeiro os registradores de número mais baixo. Suponha que a declaração de função para func seja “int f(int a, int b);”. O código para a função f é o seguinte:
2.35 [5] Podemos usar a otimização “tail-call” nesta função? Se negativo, explique por que não. Se afirmativo, qual é a diferença no número de instruções executadas em f com e sem a otimização? 2.36 [5] Imediatamente antes que a sua função f do Exercício 2.34 retorne, o que sabemos sobre o conteúdo dos registradores $t5, $s3, $ra e $sp? Lembre-se de que sabemos o conteúdo da função f, mas, para a função func, só conhecemos sua declaração. 2.37 [5] Escreva um programa em linguagem assembly do MIPS para converter uma string de números ASCII contendo strings decimais inteiras positivas e negativas para um inteiro. Seu programa deverá esperar que o registrador $a0 contenha o endereço de uma string terminada em nulo, contendo alguma combinação dos dígitos de 0 até 9. Seu programa deverá calcular o valor inteiro equivalente a essa string de dígitos, depois colocar o número no registrador $v0. Se um caractere que não seja um dígito aparecer em qualquer lugar da string, seu programa deverá parar com um valor -1 no registrador $v0. Por exemplo, se o registrador $a0 apontar para uma sequência de três bytes 50dec, 52dec, 0dec (a string “24” terminada em nulo), então, quando o programa terminar, o registrador $v0 deverá conter o valor 24dec. 2.38 [5] Considere o código a seguir:
Suponha que o registrador $t1 contenha o endereço 0x1000 0000 e o registrador $t2 contenha o endereço 0x1000 0010. Observe que a arquitetura MIPS utiliza o endereçamento big-endian. Suponha que os dados (em hexadecimal) no endereço 0x1000 0000 sejam: 0x11223344. Que valor é armazenado no endereço apontado pelo registrador $t2? 2.39 [5] Escreva o código assembly MIPS que cria a constante de 32 bits 0010 0000 0000 0001 0100 1001 0010 0100bin e armazena esse valor no registrador $t1. 2.40 [5] Se o valor atual do PC é 0x00000000, você pode usar uma única instrução jump para chegar ao endereço do PC, como mostra o Exercício 2.39? 2.41 [5] Se o valor atual do PC é 0x00000600, você pode usar uma única instrução branch para chegar ao endereço do PC, como mostra o Exercício 2.39? 2.42 [5] Se o valor atual do PC é 0x1FFFF000, você pode usar uma única instrução branch para chegar ao endereço do PC, como mostra o Exercício 2.39? 2.43 [5] Escreva o código assembly MIPS para implementar o seguinte código em C:
Suponha que o endereço da variável lk esteja em $a0, o endereço da variável shvar esteja em $a1 e o valor da variável x esteja em $a2. Sua seção crítica não deverá conter quaisquer chamadas de função. Use instruções ll/sc a fim de implementar a operação lock(), e a operação unlock() é simplesmente uma instrução store comum. 2.44 [5] Repita o Exercício 2.43, mas desta vez use ll/sc para realizar uma atualização atômica da variável shvar diretamente, sem usar lock() e
unlock(). Observe que, neste problema, não existe uma variável lk.
2.45 [5] Usando o seu código do Exercício 2.43 como exemplo, explique o que acontece quando dois processadores começam a executar essa seção crítica ao mesmo tempo, supondo que cada processador executa exatamente uma instrução por ciclo. 2.46 Suponha que, para determinado processador, o CPI das instruções aritméticas seja 1, o CPI das instruções load/store seja 10 e o CPI das instruções branch seja 3. Suponha que um programa tenha os seguintes desmembramentos de instrução: 500 milhões de instruções aritméticas, 300 milhões de instruções load/store e 100 milhões de instruções branch. 2.46.1 [5] Suponha que instruções aritméticas novas, mais poderosas, sejam acrescentadas ao conjunto de instruções. Na média, com o uso dessas instruções aritméticas mais poderosas, podemos reduzir em 25% o número de instruções aritméticas necessárias para executar um programa, e em apenas 10% o custo do aumento do tempo de ciclo de clock. Essa é uma boa escolha de projeto? Por quê? 2.46.2 [5] Suponha que achemos um meio de dobrar o desempenho das instruções aritméticas. Qual é o ganho de velocidade geral de nossa máquina? E se achássemos um modo de melhorar o desempenho das instruções aritméticas em 10 vezes? 2.47 Suponha que, para determinado programa, 70% das instruções executadas sejam aritméticas, 10% sejam load/store e 20% sejam branch. 2.47.1 [5] Dada a mistura de instruções e a suposição de que uma instrução aritmética usa 2 ciclos, uma instrução load/store usa 6 ciclos e uma instrução branch usa 3 ciclos, ache o CPI médio. 2.47.2 [5] Para uma melhoria de 25% no desempenho, quantos ciclos, em média, uma instrução aritmética pode usar se instruções load/store e branch não tiverem qualquer melhoria? 2.47.3 [5] Para uma melhoria de 50% no desempenho, quantos ciclos, em média, uma instrução aritmética pode usar se instruções load/store e branch não tiverem qualquer melhoria?
Respostas das Seções “Verifique você mesmo” §2.2, página 57: MIPS, C, Java §2.3, página 62: 2) Muito lento §2.4, página 68: 2) –8dec §2.5, página 75: 4) sub $s2, $s0, $s1
§2.6, página 78: Ambos. AND com um padrão de máscara de 1s deixa 0s em todo lugar menos no campo desejado. O deslocamento à esquerda pela quantidade correta remove os bits da esquerda do campo. O deslocamento à direita pela quantidade apropriada coloca o campo nos bits mais à direita da palavra, com 0s no restante da palavra. Observe que AND deixa o campo onde ele estava originalmente e o par deslocado move o campo para a parte mais à direita da palavra. §2.7, página 83: I. Todos são verdadeiros. II. 1). §2.8, página 92: Ambos são verdadeiros. §2.9, página 97: I. 2) II. 3) §2.10, página 104: I. 4) +–128K. II. 6) um bloco de 256M. III. 4) sll §2.11, página 107: Ambos são verdadeiros. §2.12, página 115: 4) Independência de máquina.
Aritmética Computacional A precisão numérica é a própria alma da ciência. Sir D’arcy Wentworth Thompson On Growth and Form, 1917
3.1 Introdução 3.2 Adição e subtração 3.3 Multiplicação 3.4 Divisão 3.5 Ponto flutuante 3.6 Paralelismo e aritmética computacional: paralelismo subword 3.7 Vida real: Extensões SIMD streaming e extensões avançadas de vetor no x86 3.8 Mais rápido: Paralelismo subword e multiplicação matricial 3.9 Falácias e armadilhas 3.10 Comentários finais 3.11 Exercícios
Os cinco componentes clássicos de um computador
3.1. Introdução As palavras do computador são compostas de bits; assim, podem ser representadas como números binários. O Capítulo 2 mostra que os inteiros podem ser representados em formato decimal ou binário, mas e quanto aos outros números que ocorrem normalmente? Por exemplo: ▪ Como são representadas frações e outros números reais?
▪ O que acontece se uma operação cria um número maior do que poderia ser representado? ▪ E por trás de todas essas perguntas existe um mistério: como o hardware realmente multiplica ou divide números? O objetivo deste capítulo é desvendar esses mistérios, incluindo a representação dos números, algoritmos aritméticos, hardware que acompanha esses algoritmos e as implicações de tudo isso para os conjuntos de instruções. Essas ideias podem ainda explicar truques que você já pode ter encontrado nos computadores. Além do mais, mostramos como usar esse conhecimento para tornar muito mais velozes os programas que fazem uso intenso da aritmética.
3.2. Adição e subtração Subtração: o companheiro esquisito da adição No 10, Top Ten Courses for Athletes at a Football Factory, David Letterman et al., Book of Top Ten Lists, 1990 A adição é exatamente o que você esperaria nos computadores. Dígitos são somados bit a bit, da direita para a esquerda, com carries (“vai-uns”) sendo passados para o próximo dígito à esquerda, como você faria manualmente. A subtração utiliza a adição: o operando apropriado é simplesmente negado antes de ser somado.
Adição e subtração binária Exemplo Vamos tentar somar 6dec a 7dec em binário e depois subtrair 6dec de 7dec em binário.
Os 4 bits à direita fazem toda a ação; a Figura 3.1 mostra as somas e os carries. Os carries aparecem entre parênteses, com as setas mostrando como são passados.
FIGURA 3.1 Adição binária, mostrando carries da direta para a esquerda. O bit mais à direita adiciona 1 a 0, resultando em uma soma de 1 e um carry out de 0 para esse bit. Logo, a operação para o segundo dígito da direita é 0 + 1 + 1. Isso gera uma soma de 0 e um carry out de 1 para esse bit. O terceiro dígito é a soma de 1 + 1 + 1, resultando em um carry out de 1 e uma soma de 1 para esse dígito. O quarto bit é 1 + 0 + 0, tendo uma soma de 1 e nenhum carry.
Resposta A subtração de 6dec de 7dec pode ser feita diretamente:
ou por meio da soma, usando a representação de complemento de dois de – 6:
Já dissemos que o overflow ocorre quando o resultado de uma operação não pode ser representado com o hardware disponível, nesse caso, uma palavra de 32 bits. Quando pode ocorrer um overflow na adição? Quando se somam operandos com sinais diferentes, não poderá haver overflow. O motivo é que a soma não pode ser maior do que um dos operandos. Por exemplo, –10 + 4 = –6. Como os operandos cabem nos 32 bits, e a soma não é maior do que um operando, a soma também precisa caber nos 32 bits. Portanto, nenhum overflow pode ocorrer ao somar operandos positivos e negativos. Existem restrições semelhantes à ocorrência do overflow durante a subtração, mas esse é apenas o princípio oposto: quando os sinais dos operandos são iguais, o overflow não pode ocorrer. Para ver isso, lembre-se de que x – y = x + (-y), pois subtraímos negando o segundo operando e depois somamos. Assim, quando subtraímos operandos do mesmo sinal, acabamos somando operandos de sinais diferentes. Pelo parágrafo anterior, sabemos que não pode ocorrer overflow também nesse caso. Tendo examinado quando um overflow não pode ocorrer na adição e na subtração, ainda não respondemos como detectar quando ele ocorre. Logicamente, a soma ou a subtração de dois números de 32 bits pode gerar um resultado que precisa de 33 bits para ser totalmente expresso. A falta de um 33° bit significa que, quando o overflow ocorre, o bit de sinal está sendo definido com o valor do resultado, no lugar do sinal apropriado do resultado. Como precisamos apenas de um bit extra, somente o bit de sinal pode estar errado. Logo, o overflow ocorre quando se somam dois números positivos, e a soma é negativa ou vice-versa. Isso significa que um carry ocorreu no bit de sinal. O overflow ocorre na subtração quando subtraímos um número negativo de um número positivo e obtemos um resultado negativo ou quando subtraímos um número positivo de um número negativo e obtemos um resultado positivo. Isso significa que houve um empréstimo do bit de sinal. A Figura 3.2 mostra a combinação de operações, operandos e resultados que indicam um overflow.
FIGURA 3.2 Condições de overflow para adição e subtração.
Acabamos de ver como detectar o overflow para os números em complemento de dois em um computador. E com relação aos inteiros sem sinal? Os inteiros sem sinal normalmente são usados para endereços de memória em que os overflows são ignorados. Logo, o projetista de computador precisa oferecer uma maneira de ignorar o overflow em alguns casos e reconhecê-lo em outros. A solução do MIPS é ter dois tipos de instruções aritméticas para reconhecer as duas escolhas: ▪ Adição (add), adição imediata (addi) e subtração (sub) causam exceções no overflow. ▪ Adição sem sinal (addu), adição imediata sem sinal (addiu) e subtração sem sinal (subu) não causam exceções no overflow. Como a linguagem C ignora os overflows, os compiladores C do MIPS sempre gerarão as versões sem sinal das instruções aritméticas addu, addiu e subu, sem importar o tipo das variáveis. No entanto, os compiladores Fortran do MIPS apanham as instruções aritméticas apropriadas, dependendo do tipo dos operandos. O Apêndice B descreve o hardware que realiza a adição e subtração, que é chamado de Unidade Lógica e Aritmética ou ALU
Unidade Lógica e Aritmética (ALU) Hardware que realiza adição, subtração e normalmente operações lógicas como AND e OR.
Detalhamento Uma fonte de confusão constante para addiu é o seu nome e o que acontece com seu campo imediato. O u significa unsigned (sem sinal), o que significa
que a adição não pode causar uma exceção de overflow. Porém, o campo imediato de 16 bits é estendido por sinal para 32 bits, assim como addi, slti e sltiu. Assim, o campo imediato é sinalizado, mesmo que a operação seja “unsigned”.
Interface hardware/software O projetista de computador precisa decidir como tratar overflows aritméticos. Embora algumas linguagens como C e Java ignorem o overflow de inteiros, linguagens como Ada e Fortran exigem que o programa seja notificado. O programador ou o ambiente de programação precisa, então, decidir o que fazer quando ocorre o overflow. O MIPS detecta o overflow com uma exceção, também chamada de interrupção em muitos computadores. Uma exceção ou interrupção é basicamente uma chamada de procedimento não planejada. O endereço da instrução que gerou o overflow é salvo em um registrador e o computador desvia para um endereço predefinido, a fim de invocar a rotina apropriada para essa exceção. O endereço interrompido é salvo de modo que, em algumas situações, o programa possa continuar após o código corretivo ser executado. (A Seção 4.9 abrange as exceções com mais detalhes; o Capítulo 5 descreve outras situações em que ocorrem exceções e interrupções.) O MIPS inclui um registrador, chamado contador de programa de exceção (EPC — Exception Program Counter), para conter o endereço da instrução que causou a exceção. A instrução move from system control (mfc0) é usada a fim de copiar o EPC para um registrador de uso geral, de modo que o software do MIPS tem a opção de retornar à instrução problemática por meio de uma instrução jump register.
exceção Também chamada interrupção. Um evento não planejado que interrompe a execução do programa; usada para detectar overflow.
interrupção Uma exceção que vem de fora do processador. (Algumas arquiteturas utilizam o termo interrupção para todas as exceções.)
Resumo A questão principal desta seção é que, independentemente da representação, o tamanho finito da palavra dos computadores significa que as operações aritméticas podem criar resultados muito grandes para caber nesse tamanho de palavra fixo. É fácil detectar o overflow em números sem sinal, embora quase sempre sejam ignorados, pois os programas não querem detectar overflow para a aritmética de endereço, o uso mais comum dos números naturais. O complemento de dois apresenta um desafio maior, embora alguns sistemas de software exijam detecção de overflow, de modo que, hoje, todos os computadores tenham um meio de detectá-lo. Algumas linguagens de programação permitem a aritmética de inteiros em complemento de dois com variáveis declaradas com um byte e meio, enquanto MIPS tem apenas operações aritméticas de inteiros sobre palavras completas. Conforme vimos no Capítulo 2, MIPS não possui operações de transferência de dados para bytes e halfwords. Que instruções do MIPS deveriam ser geradas para as operações aritméticas de byte e halfword? 1. Load com lbu, lhu; aritmética com add, sub, mult, div; depois, armazenamento usando sb, sh. 2. Load com lb, lh; aritmética com add, sub, mult, div; depois, armazenamento usando sb, sh. 3. Load com lb, lh; aritmética com add, sub, mult, div, usando AND para mascarar o resultado com 8 ou 16 bits após cada operação; depois, armazenamento usando sb, sh.
Detalhamento Um recurso que geralmente não é encontrado em microprocessadores de uso geral é a saturação de operações. A saturação significa que, quando um cálculo gera overflow, o resultado é definido para o maior número positivo ou o maior número negativo, ao invés de um cálculo de módulo, como na aritmética do complemento de dois. A saturação provavelmente é o que você deseja para operações com mídia. Por exemplo, o botão de volume em um aparelho de rádio seria frustrante se, quando girado, o volume ficasse continuamente mais alto por um tempo e depois, repentinamente, muito baixo. Um botão com saturação pararia no volume mais alto, não importa o quanto você o girasse. As extensões de multimídia para os conjuntos de instruções padrão geralmente oferecem aritmética com saturação.
Detalhamento MIPS pode interceptar o overflow, porém, diferente de muitos outros computadores, não existe desvio condicional para testar o overflow. Uma sequência de instruções MIPS pode descobrir o overflow. Para a adição com sinal, a sequência é a seguinte (veja o Detalhamento no Capítulo 2, para obter uma descrição da instrução xor):
Para a adição sem sinal ($t0 = $t1 + $t2), o teste é
Detalhamento No texto anterior, dissemos que você copia o EPC para um registrador por meio de mfc0 e depois retorna ao código interrompido por meio de jump register. Isso leva a uma pergunta interessante: já que você primeiro precisa transferir o EPC para um registrador a fim de usar com jump register, como jump register pode retornar ao código interrompido e restaurar os valores originais de todos os registradores? Você restaura os registradores antigos primeiro, destruindo, assim, seu endereço de retorno do EPC, que colocou em
um registrador para uso em jump register, ou restaura todos os registradores, menos aquele com o endereço de retorno, para que possa desviar – significando que uma exceção resultaria em alterar esse único registrador a qualquer momento durante a execução do programa! Nenhuma dessas opções é satisfatória. Para auxiliar o hardware neste dilema, os programadores MIPS concordaram em reservar os registradores $k0 e $k1 para o sistema operacional; esses registradores não são restaurados nas exceções. Assim como os compiladores MIPS evitam o uso do registrador $at, de modo que o montador possa utilizá-lo como um registrador temporário (veja a Seção “Interface Hardware/Software”, na Seção 2.10), os compiladores também se abstêm do uso dos registradores $k0 e $k1, de modo que fiquem disponíveis para o sistema operacional. As rotinas de exceção colocam o endereço de retorno em um desses registradores e depois usam o jump register para armazenar o endereço da instrução.
Detalhamento A velocidade da adição é aumentada, determinando-se o carry in para os bits de alta ordem mais cedo. Existem diversos esquemas para antecipar o carry, de modo que o pior que pode acontecer é uma função do log2 do número de bits no somador. Esses sinais antecipados são mais rápidos, pois percorrem menos portas na sequência, mas exigem mais portas para antecipar o carry apropriado. O mais comum é o carry lookahead, descrito na Seção B.6 do Apêndice B.
3.3. Multiplicação Multiplicação é vexação, Divisão também é ruim; A regra de três me intriga, e a prática me deixa louco. Anônimo, manuscrito Elisabetano, 1570 Agora que completamos a explicação de adição e subtração, estamos prontos para montar a operação mais difícil da multiplicação.
Primeiro, vamos rever a multiplicação de números decimais à mão para nos lembrar das etapas e dos nomes dos operandos. Por motivos que logo se tornarão claros, limitamos este exemplo decimal ao uso apenas dos dígitos 0 e 1. Multiplicando 1000dec por 1001dec:
O primeiro operando é chamado multiplicando e o segundo é o multiplicador. O resultado final é chamado produto. Como você pode se lembrar, o algoritmo aprendido na escola é pegar os dígitos do multiplicador um a um, da direita para a esquerda, calculando a multiplicação do multiplicando pelo único dígito do multiplicador e deslocando o produto intermediário um dígito para a esquerda dos produtos intermediários anteriores. A primeira observação é que o número de dígitos no produto é muito maior do que o número no multiplicando ou no multiplicador. De fato, se ignorarmos os bits de sinal, o tamanho da multiplicação de um multiplicando de n bits por um
multiplicador de m bits é um produto que possui n + m bits de largura. Ou seja, n + m bits são necessários para representar todos os produtos possíveis. Logo, como na adição, a multiplicação precisa lidar com o overflow, pois constantemente desejamos um produto de 32 bits como resultado da multiplicação de dois números de 32 bits. Neste exemplo, restringimos os dígitos decimais a 0 e 1. Com somente duas opções, cada etapa da multiplicação é simples: 1. Basta colocar uma cópia do multiplicando (1 × multiplicando) no lugar apropriado se o dígito do multiplicador for 1 ou 2. Colocar 0 (0 × multiplicando) no lugar apropriado se o dígito for 0. Embora o exemplo decimal anterior utilize apenas 0 e 1, a multiplicação de números binários sempre usa 0 e 1 e, por isso, sempre oferece apenas essas duas opções. Agora que já revisamos os fundamentos tradicionais da multiplicação, a próxima etapa é mostrar o hardware de multiplicação altamente otimizado. Quebramos essa tradição na crença de que você entenderá melhor vendo a evolução do hardware e do algoritmo de multiplicação no decorrer das diversas gerações. Por enquanto, vamos supor que estamos multiplicando apenas números positivos.
Versão sequencial do algoritmo e hardware de multiplicação Esse projeto imita o algoritmo que aprendemos na escola; o hardware aparece na Figura 3.3. Desenhamos o hardware de modo que os dados fluam de cima para baixo, para que fique mais semelhante à técnica do lápis e papel.
FIGURA 3.3 Primeira versão do hardware de multiplicação. O registrador do Multiplicando, a ALU, e o registrador do Produto possuem 64 bits de largura, apenas com o registrador do Multiplicador contendo 32 bits. (O Apêndice B descreve as ALUs.) O multiplicando com 32 bits começa na metade direita do registrador do Multiplicando e é deslocado à esquerda 1 bit em cada etapa. O multiplicador é deslocado na direção oposta em cada etapa. O algoritmo começa com o produto inicializado com 0. O controle decide quando deslocar os registradores Multiplicando e Multiplicador e quando escrever novos valores no registrador do Produto.
Vamos supor que o multiplicador esteja no registrador Multiplicador de 32 bits e que o registrador Produto de 64 bits esteja inicializado como 0. Pelo exemplo de lápis e papel, visto anteriormente, fica claro que precisaremos mover o multiplicando para a esquerda um dígito a cada passo, pois pode ser somado aos produtos intermediários. Durante 32 etapas, um multiplicando de 32 bits moveria 32 bits para a esquerda. Logo, precisamos de um registrador Multiplicando de 64 bits, inicializado com o multiplicando de 32 bits na metade direita e 0 na metade esquerda. Esse registrador, em seguida, é deslocado 1 bit para a esquerda a cada etapa, de modo a alinhar o multiplicando com a soma sendo acumulada no registrador Produto de 64 bits. A Figura 3.4 mostra as três etapas clássicas necessárias para cada bit. O bit menos significativo do multiplicador (Multiplicador0) determina se o
multiplicando é somado ao registrador Produto. O deslocamento à esquerda na etapa 2 tem o efeito de mover os operandos intermediários para a esquerda, assim como na multiplicação manual. O deslocamento à direita na etapa 3 nos indica o próximo bit do multiplicador a ser examinado na iteração seguinte. Essas três etapas são repetidas 32 vezes, para obter o produto. Se cada etapa usasse um ciclo de clock, esse algoritmo exigiria quase 100 ciclos de clock para multiplicar dois números de 32 bits. A importância relativa de operações aritméticas, como a multiplicação, varia com o programa, mas a soma e a subtração podem ser de 5 a 100 vezes mais comuns do que a multiplicação. Como consequência, em muitas aplicações, a multiplicação pode demorar vários ciclos de clock sem afetar o desempenho de forma significativa. Mesmo assim, a lei de Amdahl (Seção 1.10) nos lembra que até mesmo uma frequência moderada para uma operação lenta pode limitar o desempenho.
FIGURA 3.4 O primeiro algoritmo de multiplicação, usando o hardware mostrado na Figura 3.3. Se o bit menos significativo do multiplicador for 1, some o multiplicando ao produto. Caso contrário, vá para a etapa
seguinte. Desloque o multiplicando para a esquerda e o multiplicador para a direita nas duas etapas seguintes. Essas três etapas são repetidas 32 vezes.
Esse algoritmo e o hardware são facilmente refinados para usar 1 ciclo de clock por etapa. O aumento de velocidade vem da realização das operações em paralelo: o multiplicador e o multiplicando são deslocados enquanto o multiplicando é somado ao produto se o bit do multiplicador for 1. O hardware simplesmente precisa garantir que testará o bit da direita do multiplicador e receberá a versão previamente deslocada do multiplicando. O hardware normalmente é otimizado ainda mais para dividir a largura do somador e dos registradores ao meio, observando onde existem partes não utilizadas dos registradores e somadores. A Figura 3.5 mostra o hardware revisado.
FIGURA 3.5 Versão refinada do hardware de multiplicação. Compare com a primeira versão na Figura 3.3. O registrador Multiplicando, a ALU, e o registrador Multiplicador possuem 32 bits de extensão, com somente o registrador Produto restando nos 64 bits. Agora, o produto é deslocado para a direita. O registrador Multiplicador separado também desapareceu. O multiplicador é colocado na metade direita do registrador Produto. Essas mudanças estão destacadas. (O registrador Produto, na realidade, deverá ter 65 bits, a fim de manter o carry do somador, mas ele aparece aqui como 64 bits para destacar a evolução da Figura 3.3.)
FIGURA 3.6 Exemplo de multiplicação usando o algoritmo da Figura 3.4. O bit examinado para determinar a próxima etapa está em destaque.
Interface hardware/software A substituição da aritmética por deslocamentos também pode ocorrer quando se multiplica por constantes. Alguns compiladores substituem multiplicações por constantes curtas com uma série de deslocamentos e adições. Como deslocar um bit à esquerda representa um número duas vezes maior na base 2, o deslocamento de bits para a esquerda tem o mesmo efeito de multiplicar por uma potência de 2. Como dissemos no Capítulo 2, quase todo compilador realizará a otimização por redução de força, substituindo uma multiplicação na potência de 2 por um deslocamento à esquerda.
Um algoritmo de multiplicação Exemplo Usando números de 4 bits para economizar espaço, multiplique 2dec × 3dec ou 0010bin × 0011bin.
Resposta
A Figura 3.6 mostra o valor de cada registrador para cada uma das etapas rotuladas de acordo com a Figura 3.4, com o valor final de 0000 0110bin ou 6dec. O negrito é usado para indicar os valores de registrador que mudam nessa etapa e o bit circulado é aquele examinado para determinar a operação da próxima etapa.
Multiplicação com sinal Até aqui, tratamos de números positivos. O modo mais fácil de entender como tratar dos números com sinal é primeiro converter o multiplicador e o multiplicando para números positivos e depois lembrar dos sinais originais. Os algoritmos deverão, então, ser executados por 31 iterações, deixando os sinais fora do cálculo. Conforme aprendemos na escola, o produto só será negativo se os sinais originais forem diferentes. Acontece que o último algoritmo funcionará para números com sinais se nos lembrarmos de que os números com que estamos lidando possuem dígitos infinitos e que só os estamos representando com 32 bits. Logo, as etapas de deslocamento precisariam estender o sinal do produto para números com sinal. Quando o algoritmo terminar, a palavra menos significativa terá o produto de 32 bits.
Multiplicação mais rápida A Lei de Moore ofereceu tantos recursos que os projetistas de hardware agora podem construir um hardware de multiplicação muito mais rápido. No início da multiplicação, já se sabe se o multiplicando deve ser somado ou não, analisando cada um dos 32 bits do multiplicador. Multiplicações mais rápidas são possíveis basicamente fornecendo um somador de 32 bits para cada bit do multiplicador: uma entrada é o AND do multiplicando pelo bit do multiplicador e a outra é a saída de um somador anterior.
Uma técnica simples seria conectar as saídas dos somadores à direita das entradas dos somados à esquerda, criando uma pilha de somadores com altura 32. Um modo alternativo de organizar essas 32 adições é em uma árvore paralela, como mostra a Figura 3.7. Em vez de esperar 32 tempos de add, esperamos apenas o log2(32) ou cinco tempos de add de 32 bits.
FIGURA 3.7 Hardware da multiplicação rápida. Em vez de usar um único somador de 32 bits 31 vezes, esse hardware “desenrola o loop” para usar 31 somadores e depois os organiza para minimizar o atraso.
De fato, a multiplicação pode se tornar ainda mais rápida do que cinco tempos de add, veja uso de somadores para salvar carry (Seção B.6 no Apêndice B) e porque é fácil usar um pipeline nesse projeto para que possam ser realizadas muitas multiplicações simultaneamente (Capítulo 4).
Multiplicação no MIPS O MIPS oferece um par separado de registradores de 32 bits, a fim de conter o produto de 64 bits, chamados Hi e Lo. Para produzir um produto com ou sem o devido sinal, o MIPS possui duas instruções: multiply (mult) e multiply unsigned (multu). Para apanhar o produto de 32 bits inteiro, o programador usa move from lo (mflo). O montador MIPS gera uma pseudoinstrução para multiplicar, que especifica três registradores de uso geral, criando instruções mflo e mfhi que colocam o produto nos registradores.
Resumo A multiplicação é feita pelo hardware simples de deslocamento e adição,
derivado do método de lápis e papel que aprendemos na escola. Os compiladores utilizam até mesmo as instruções de deslocamento para multiplicações por potências de dois. Com muito mais hardware, podemos fazer as adições em paralelo, tornando-as muito mais rápidas.
Interface hardware/software As duas instruções multiply do MIPS ignoram o overflow, de modo que fica a critério do software verificar se o produto é muito grande para caber nos 32 bits. Não existe overflow se Hi for 0 para multu ou o sinal replicado de Lo para mult. A instrução move from hi (mfhi) pode ser usada para transferir Hi a um registrador de uso geral, a fim de testar o overflow.
3.4. Divisão Divide et impera. Tradução do latim para “Dividir e conquistar”, máxima política antiga, citada por Maquiavel, 1532
citada por Maquiavel, 1532 A operação recíproca da multiplicação é a divisão, ainda menos frequente e ainda mais peculiar. Ela oferece até mesmo a oportunidade de realizar uma operação matematicamente inválida: dividir por 0. Vamos começar com um exemplo de divisão longa usando números decimais, para lembrar os nomes dos operandos e do algoritmo de divisão que aprendemos na escola. Por motivos semelhantes aos da seção anterior, vamos limitar os dígitos decimais a apenas 0 ou 1. O exemplo é a divisão de 1.001.010dec por 1000dec:
Os dois operandos (dividendo e divisor) e o resultado (quociente) da divisão são acompanhados por um segundo resultado, chamado resto. Veja aqui outra maneira de expressar o relacionamento entre os componentes:
em que o resto é menor do que o divisor. Raramente, os programas utilizam a instrução de divisão só para obter o resto, ignorando o quociente.
dividendo
Um número sendo dividido.
divisor Um número pelo qual o dividendo é dividido.
quociente O resultado principal de uma divisão; um número que, quando multiplicado pelo divisor e somado ao resto, produz o dividendo.
resto O resultado secundário de uma divisão; um número que, quando somado ao produto do quociente pelo divisor, produz o dividendo. O algoritmo básico de divisão, que aprendemos na escola, tenta ver o quanto um número pode ser subtraído, criando um dígito do quociente em cada tentativa. Nosso exemplo decimal cuidadosamente selecionado usa apenas os números 0 e 1, de modo que é fácil descobrir quantas vezes o divisor cabe na parte do dividendo: deve ser 0 ou 1. Os números binários contêm apenas 0 ou 1, de modo que a divisão binária é restrita a essas duas opções, simplificando, assim, a divisão binária. Vamos supor que o dividendo e o divisor sejam positivos e, logo, o quociente e o resto sejam não negativos. Os operandos da divisão e os dois resultados são valores de 32 bits, e ignoraremos o sinal por enquanto.
Algoritmo e hardware de divisão A Figura 3.8 mostra o hardware para imitar nosso algoritmo da escola. Começamos com o registrador Quociente de 32 bits definido como 0. Cada iteração do algoritmo precisa deslocar o divisor para a direita um dígito, de modo que começaremos com o divisor colocado na metade esquerda do registrador Divisor de 64 bits e o deslocaremos para a direita 1 bit a cada etapa, a fim de alinhá-lo com o dividendo. O registrador Resto é inicializado com o dividendo.
FIGURA 3.8 Primeira versão do hardware de divisão. O registrador Divisor, a ALU e o registrador Resto possuem 64 bits de largura, com apenas o registrador Quociente tendo 32 bits. O divisor de 32 bits começa na metade esquerda do registrador Divisor e é deslocado 1 bit para a direita em cada iteração. O resto é inicializado com o dividendo. O controle decide quando deslocar os registradores Divisor e Quociente e quando escrever o novo valor para o registrador Resto.
A Figura 3.9 mostra três etapas do primeiro algoritmo de divisão. Ao contrário dos humanos, o computador não é inteligente o bastante para saber, com antecedência, se o divisor é menor do que o dividendo. Ele primeiro precisa subtrair o divisor na etapa 1; lembre-se de que é assim que realizamos a comparação na instrução set on less than. Se o resultado for positivo, o divisor foi menor ou igual ao dividendo, de modo que geramos um 1 no quociente (etapa 2a). Se o resultado é negativo, a próxima etapa é restaurar o valor original, somando o divisor de volta ao resto, gerando um 0 no quociente (etapa 2b). O divisor é deslocado para a direita e depois repetimos. O resto e o quociente serão encontrados em seus registradores de mesmo nome depois que as iterações terminarem.
FIGURA 3.9 Um algoritmo de divisão, usando o hardware da Figura 3.8. Se o Resto é positivo, o divisor coube no dividendo, de modo que a etapa 2a gera um 1 no quociente. Um Resto negativo após a etapa 1 significa que o divisor não coube no dividendo, de modo que a etapa 2b gera um 0 no quociente e soma o divisor
ao resto, revertendo, assim, a subtração da etapa 1. O deslocamento final, na etapa 3, alinha o divisor corretamente, em relação ao dividendo, para a próxima iteração. Essas etapas são repetidas 33 vezes.
FIGURA 3.10 Exemplo de divisão usando o algoritmo da Figura 3.9. O bit examinado para determinar a próxima etapa está em destaque.
Um algoritmo de divisão Exemplo Usando uma versão de 4 bits do algoritmo para economizar páginas, vamos tentar dividir 7dec por 2dec ou 0000 0111bin por 0010bin.
Resposta A Figura 3.10 mostra o valor de cada registrador para cada uma das etapas, com o quociente sendo 3dec e o resto sendo 1dec. Observe que o teste na etapa 2 (se o resto é positivo ou negativo) simplesmente testa se o bit de sinal do registrador Resto é um 0 ou um 1. O requisito surpreendente desse algoritmo é
que ele utiliza n + 1 etapas para obter o quociente e resto corretos. Esse algoritmo e esse hardware podem ser refinados para que sejam mais rápidos e menos dispendiosos. A rapidez vem do deslocamento dos operandos e do quociente no mesmo momento da subtração. Essa melhoria divide ao meio a largura do somador e dos registradores, ao observar onde existem partes não usadas dos registradores e somadores. A Figura 3.11 mostra o hardware revisado.
FIGURA 3.11 Uma versão melhorada do hardware de divisão. O registrador Divisor, a ALU e o registrador Quociente possuem 32 bits de largura, com apenas o registrador Resto ficando com 64 bits. Em comparação com a Figura 3.8, os registradores ALU e Divisor são divididos ao meio, e o resto é deslocado à esquerda. Esta versão também combina o registrador Quociente com a metade direita do registrador Resto. (Assim como na Figura 3.5, o registrador Resto na realidade deveria ter 65 bits para garantir que o carry do somador não se perca.)
Divisão com sinal Até aqui, ignoramos os números com sinal na divisão. A solução mais simples é
lembrar os sinais do divisor e do dividendo e depois negar o quociente se os sinais forem diferentes.
Detalhamento Uma complicação da divisão com sinal é que também temos de definir o sinal do resto. Lembre-se de que a seguinte equação precisa ser sempre mantida:
Para entender como definir o sinal do resto, vejamos o exemplo da divisão de todas as combinações de ±7dec por ±2dec. O primeiro caso é fácil:
Verificando os resultados:
Se você mudar o sinal do dividendo, o quociente também precisa mudar:
Reescrevendo nossa fórmula básica para calcular o resto:
Assim,
Verificando os resultados novamente:
O motivo pelo qual a resposta não é um quociente de –4 e um resto de +1, que também caberia nessa fórmula, é que o valor absoluto do quociente mudaria dependendo do sinal do dividendo e do divisor! Logicamente, se
a programação seria um desafio ainda maior. Esse comportamento anômalo é evitado seguindo-se a regra de que o dividendo e o resto devem ter os mesmos sinais, não importa quais sejam os sinais do divisor e do quociente. Calculamos as outras combinações seguindo a mesma regra:
Assim, o algoritmo de divisão com sinal nega o quociente se os sinais dos operandos foram opostos e faz com que o sinal do resto diferente de zero corresponda ao dividendo.
Divisão mais rápida A Lei de Moore se aplica ao hardware de divisão e também à multiplicação, de modo que provavelmente podemos agilizar a divisão jogando hardware nela. Usamos muitos somadores para agilizar a multiplicação, mas não podemos fazer o mesmo truque para a divisão. O motivo é que precisamos saber o sinal da diferença antes de podermos realizar a próxima etapa do algoritmo, enquanto, com a multiplicação, poderíamos calcular os 32 produtos parciais imediatamente.
Existem técnicas para produzir mais de um bit do quociente por etapa. A técnica de divisão SRT tenta predizer vários bits do quociente por etapa, usando uma pesquisa numa tabela baseada nos bits mais significativos do dividendo e do resto. Ela conta com as etapas subsequentes para corrigir predições erradas. Um valor comum hoje é 4 bits. A chave é descobrir o valor para subtrair. Com a divisão binária, existe somente uma única opção. Esses algoritmos utilizam 6 bits do resto e 4 bits do divisor para indexar uma tabela que determina a opção para cada etapa.
A precisão desse método rápido depende da existência de valores apropriados na tabela de pesquisa. A falácia apresentada na Seção 3.9 mostra o que pode acontecer se a tabela estiver incorreta.
Divisão no MIPS Você já pode ter observado que o mesmo hardware sequencial pode ser usado para multiplicação e divisão nas Figuras 3.5 e 3.11. O único requisito é um registrador de 64 bits, que pode deslocar para a esquerda ou para a direita e uma ALU de 32 bits que soma ou subtrai. Logo, o MIPS utiliza os registradores Hi e Lo de 32 bits, tanto para multiplicação quanto para divisão. Como poderíamos esperar do algoritmo anterior, Hi contém o resto, e Lo contém o quociente após o término da instrução de divisão. Para lidar com inteiros com sinal e inteiros sem sinal, o MIPS possui duas instruções: divide (div) e divide unsigned (divu). O montador MIPS permite que as instruções de divisão especifiquem três registradores, gerando as instruções mflo ou mfhi para colocar o resultado desejado em um registrador de uso geral.
Resumo
Resumo O suporte de hardware comum para multiplicação e divisão permite que o MIPS ofereça um único par de registradores de 32 bits usados tanto para multiplicar quanto para dividir. Aceleramos a divisão predizendo múltiplos bits do quociente e depois corrigindo erros de predição mais adiante. A Figura 3.12 resume os acréscimos à arquitetura MIPS das duas últimas seções.
FIGURA 3.12 Arquitetura MIPS revelada até aqui. A memória e os registradores da arquitetura MIPS não estão incluídos por questões de espaço, mas esta seção acrescentou os registradores Hi e Lo para dar suporte à multiplicação e à
divisão. A linguagem de máquina do MIPS aparece no Guia de Referência do MIPS, no final deste livro.
Interface hardware/software Instruções de divisão MIPS ignoram o overflow, de modo que o software precisa determinar se o quociente é muito grande. Além do overflow, a divisão também pode resultar em um cálculo impróprio: divisão por 0. Alguns computadores distinguem esses dois eventos anômalos. O software MIPS precisa verificar o divisor para descobrir a divisão por 0 e também o overflow.
Detalhamento Um algoritmo ainda mais rápido não soma imediatamente o divisor se o resto for negativo. Ele simplesmente soma o dividendo ao resto deslocado na etapa seguinte, pois (r + d) x 2 – d = r x 2 + d x 2 – d = r x 2 + d. Esse algoritmo de divisão sem restauração, que usa um clock por etapa, é explorado ainda mais nos exercícios; o algoritmo aqui apresentado é chamado de divisão com restauração. Um terceiro algoritmo, que não salva o resultado da subtração se ele for negativo, é chamado algoritmo de divisão sem o retorno esperado. Ele tem em média menos um terço de operações aritméticas.
3.5. Ponto flutuante A velocidade não o leva a lugar algum se você estiver na direção errada. Provérbio americano Indo além de inteiros com e sem sinal, as linguagens de programação admitem números com frações, que são chamados reais na matemática. Aqui estão alguns exemplos de números reais:
Observe que, no último caso, o número não representou uma fração pequena, mas foi maior do que poderíamos representar com um inteiro de 32 bits com sinal. A notação alternativa para os dois últimos números é chamada notação científica, que tem um único dígito à esquerda do ponto decimal. Um número na notação científica que não tem 0s à esquerda do ponto decimal é chamado de número normalizado, que é o modo normal como o escrevemos. Por exemplo, 1,0dec × 10-9 está em notação científica normalizada, mas 0,1dec × 10-8 e 10,0dec × 10-10 não estão.
normalizado Um número na notação de ponto flutuante que não possui 0s à esquerda do ponto decimal.
notação científica Uma notação que apresenta números com um único dígito à esquerda do ponto decimal. Assim como podemos mostrar números decimais em notação científica, também podemos mostrar números binários em notação científica:
Para manter um número binário na forma normalizada, precisamos de uma base que possamos aumentar ou diminuir exatamente pelo número de bits que o número precisa ser deslocado para ter um dígito diferente de zero à esquerda do ponto decimal. Somente uma base de 2 atende à nossa necessidade. Como a base não é 10, também precisamos de um novo nome para o ponto decimal; ponto
binário servirá bem. A aritmética computacional, que admite tais números, é chamada ponto flutuante porque representa os números em que o ponto binário não é fixo, como acontece para os inteiros. A linguagem de programação C utiliza o nome float para esses números. Assim como na notação científica, os números são representados como um único dígito diferente de zero à esquerda do ponto binário. Em binário, o formato é
ponto flutuante Aritmética computacional que representa os números em que o ponto binário não é fixo.
(Embora o computador represente o expoente na base 2, bem como o restante do número, para simplificar a notação, mostramos o expoente em decimal.) Uma notação científica padrão para os números reais no formato normalizado oferece três vantagens. Ela simplifica a troca de dados, que incluem números em ponto flutuante; simplifica os algoritmos aritméticos de ponto flutuante, por saber que os números sempre estarão nessa forma; e aumenta a precisão dos números que podem ser armazenados em uma palavra, pois os 0s desnecessários são substituídos por dígitos reais à direita do ponto binário.
Representação em ponto flutuante Um projetista de uma representação em ponto flutuante precisa encontrar um compromisso entre o tamanho da fração e o tamanho do expoente, pois um tamanho de palavra fixo significa que você precisa tirar um bit de um para acrescentar um bit ao outro. Esta decisão é entre a precisão e o intervalo: aumentar o tamanho da fração melhora a precisão da fração, enquanto aumentar o tamanho do expoente aumenta o intervalo de números que podem ser representados. Conforme nosso guia de projetos do Capítulo 2 nos lembra, um bom projeto exige um bom compromisso.
fração
O valor, geralmente entre 0 e 1, colocado no campo de fração.
expoente No sistema de representação numérica da aritmética de ponto flutuante, o valor colocado no campo de expoente. Os números em ponto flutuante normalmente são múltiplos do tamanho de uma palavra. A representação de um número em ponto flutuante MIPS aparece a seguir, em que s é o sinal do número de ponto flutuante (1 significa negativo), expoente é o valor do campo de expoente com 8 bits (incluindo o sinal do expoente) e fração é o número de 23 bits. Essa representação é chamada sinal e magnitude, pois o sinal possui um bit separado do restante do número.
Em geral, os números em ponto flutuante estão no formato
F envolve o valor no campo de fração e E envolve o valor no campo de expoente; o relacionamento exato com esses campos será explicado em breve. (Logo veremos que o MIPS faz algo ligeiramente mais sofisticado.) Esses tamanhos escolhidos de expoente e fração dão à aritmética do computador MIPS um intervalo extraordinário. Frações quase tão pequenas quanto 2,0dec × 10-38 e números quase tão grandes quanto 2,0dec × 1038 podem ser representados em um computador. Infelizmente, extraordinário é diferente de infinito, de modo que ainda é possível que os números sejam grandes demais. Assim, interrupções por overflow podem ocorrer na aritmética de ponto flutuante e também na aritmética de inteiros. Observe que overflow aqui significa que o expoente é muito grande para ser representado no campo de expoente.
overflow (ponto flutuante) Uma situação em que um expoente positivo torna-se grande demais para caber no campo de expoente. O ponto flutuante também oferece um novo tipo de evento excepcional. Assim como os programadores desejarão saber quando calcularam um número muito grande para ser representado, também desejarão saber se a fração diferente de zero que estão calculando tornou-se tão pequena que não pode ser representada; os dois eventos poderiam resultar em um programa com respostas incorretas. Para distinguir do overflow, as pessoas chamam esse evento de underflow. Essa situação ocorre quando o expoente negativo é muito grande para caber no campo de expoente.
underflow (ponto flutuante) Uma situação em que um expoente negativo torna-se grande demais para caber no campo de expoente. Uma maneira de reduzir as chances de underflow ou overflow é oferecer outro formato que tenha um expoente maior. Em C, esse número é chamado double, e as operações sobre doubles são indicadas como aritmética de ponto flutuante com precisão dupla; o ponto flutuante com precisão simples é o nome do formato anterior.
precisão dupla Um valor de ponto flutuante representado em duas palavras de 32 bits.
precisão simples Um valor de ponto flutuante representado em uma única palavra de 32 bits. A representação de um número em ponto flutuante com precisão dupla utiliza duas palavras MIPS, como vemos a seguir, em que s ainda é o sinal do número, expoente é o valor do campo de expoente em 11 bits, e fração é o número de 52 bits na fração.
A precisão dupla do MIPS permite a representação de números quase tão pequenos quanto 2,0dec × 10–308 e quase tão grandes quanto 2,0dec × 10308. Embora a precisão dupla não aumente o intervalo do expoente, sua principal vantagem é sua maior precisão, em consequência da fração muito maior. Esses formatos vão além do MIPS. Eles fazem parte do padrão de ponto flutuante IEEE 754, encontrado em praticamente todo computador inventado desde 1980. Esse padrão melhorou bastante tanto a facilidade de portar programas de ponto flutuante quanto a qualidade da aritmética computacional. Para colocar ainda mais bits no significando, o IEEE 754 deixa implícito o bit 1 inicial dos números binários normalizados. Logo, o número tem, na realidade, 24 bits de largura na precisão simples (1 implícito e fração de 23 bits) e 53 bits de extensão na precisão dupla (1 + 52). Para ser exato, usamos o termo significando para representar o número de 24 ou 53 bits que é 1 mais a fração, e fração quando queremos dizer o número de 23 ou 52 bits. Como 0 não possui um 1 inicial, ele recebe o valor de expoente reservado 0, de modo que o hardware não lhe acrescente um 1 inicial. Assim, 00…00bin representa 0; a representação do restante dos números usa a forma de antes, com o 1 oculto sendo acrescentado:
em que os bits da fração representam um número entre 0 e 1, e E especifica o valor no campo de expoente, que será explicado em detalhes mais adiante. Se numerarmos os bits da fração da esquerda para a direita de s1, s2, s3, …, então o valor é
A Figura 3.13 mostra as codificações dos números de ponto flutuante IEEE
754. Outros recursos do IEEE 754 são símbolos especiais para representar eventos incomuns. Por exemplo, em vez de interromper em uma divisão por 0, o software pode definir o resultado para um padrão de bits que represente +∞ ou – ∞; o maior expoente é reservado a esses símbolos especiais. Quando o programador exibe os resultados, o programa exibirá um símbolo de infinito. (Para os que são matematicamente treinados, a finalidade do infinito é formar o fechamento topológico dos reais.)
FIGURA 3.13 Codificação IEE 754 dos números de ponto flutuante. Um bit de sinal separado determina o sinal. Os números desnormalizados são descritos no Detalhamento na página XX222. Essa informação também é encontrada na coluna 4 do Guia de Referência do MIPS, no final deste livro.
O IEEE 754 até mesmo possui um símbolo para o resultado de operações inválidas, como 0/0 ou a subtração entre infinito e infinito. Esse símbolo é NaN, de Not a Number (não é um número). A finalidade dos NaNs é permitir que os programadores adiem alguns testes e decisões para outro momento no programa, quando for conveniente. Os projetistas do IEEE 754 também queriam uma representação de ponto flutuante que pudesse ser facilmente processada por comparações de inteiros, especialmente para ordenação. Esse desejo é o motivo pelo qual o sinal está no bit mais significativo, permitindo um teste rápido de menor que, maior que ou igual a 0. (Isso é um pouco mais complicado do que uma ordenação simples de inteiros, pois essa notação é basicamente sinal e magnitude, em vez do complemento de dois.) Colocar o expoente antes do significando simplifica a ordenação dos números de ponto flutuante usando instruções de comparação de inteiros, pois os números com expoentes grandes são maiores do que os números com expoentes menores,
desde que os dois expoentes tenham o mesmo sinal. Expoentes negativos impõem um desafio à ordenação simplificada. Se usarmos o complemento de dois ou qualquer outra notação em que os expoentes negativos têm um 1 no bit mais significativo do campo de expoente, um expoente negativo se parecerá com um número grande. Por exemplo, 1,0bin × 2-1 seria representado como
(Lembre-se de que o 1 à esquerda do ponto é implícito no significando.) O valor 1,0bin × 2+1 seria semelhante a um número binário menor
A notação desejável, portanto, precisa representar o expoente mais negativo como 00 … 00bin e o mais positivo como 11 … 11bin. Essa convenção é chamada notação deslocada, com o bias sendo o número subtraído da representação normal, sem sinal, para determinar o valor real. O IEEE 754 usa um bias de 127 para a precisão simples, de modo que um expoente –1 é representado pelo padrão de bits do valor –1 + 127dec ou 126dec = 0111 1110bin, e +1 é representado por 1 + 127 ou 128dec = 1000 0000bin. O bias do expoente para a precisão dupla é 1023. O expoente deslocado significa que o valor representado por um número em ponto flutuante é, na realidade:
A faixa de números de precisão simples é, então, desde
até
Vamos mostrar a representação.
Representação de ponto flutuante Exemplo Mostre a representação binária IEEE 754 para o número –0,75dec em precisão simples e dupla.
Resposta O número –0,75dec também é
Ele também é representado pela fração binária
Em notação científica, o valor é
e, na notação científica normalizada, ele é
A representação geral para um número de precisão simples é
Quando subtraímos o bias 127 do expoente de –1,1bin × 2–1, o resultado é
A representação binária de precisão simples de –0,75dec, é, portanto:
A representação em precisão dupla é
Agora, vamos experimentar na outra direção.
Convertendo ponto flutuante binário para decimal Exemplo
Que número decimal é representado por este float de precisão simples?
Resposta O bit de sinal é 1, o campo de expoente contém 129 e o campo de fração contém 1 × 2–2 = 1/4 ou 0,25. Usando a equação básica,
Nas próximas seções, daremos os algoritmos para a adição e multiplicação em ponto flutuante. Em seu núcleo, eles utilizam operações inteiras correspondentes nos significandos, mas é preciso que haja manutenção extra para lidar com os expoentes e normalizar o resultado. Primeiro, oferecemos uma derivação intuitiva dos algoritmos em decimal, e depois uma versão mais detalhada, binária, nas figuras.
Detalhamento Seguindo as diretrizes do IEEE, o comitê IEEE 754 foi modificado 20 anos depois do padrão para ver quais mudanças deveriam ser feitas ou se deveriam. O padrão revisado IEEE 754-2008 inclui quase todo o IEEE 754-1985 e acrescenta um formato de 16 bits (“meia precisão”) e um formato de 128 bits (“precisão quádrupla”). Nenhum hardware foi criado para dar suporte à precisão quádrupla, mas ele certamente virá. O padrão revisado também acrescenta aritmética de ponto flutuante, que os mainframes IBM implementaram.
Detalhamento Em uma tentativa de aumentar o intervalo sem remover bits do significando, alguns computadores antes do padrão IEEE 754 usavam uma base diferente de 2. Por exemplo, os computadores mainframe IBM 360 e 370 usam a base 16. Como mudar o expoente no IBM em um significa deslocar o significando em 4 bits, os números de base 16 “normalizados” podem ter até 3 bits 0 à esquerda do ponto! Logo, os dígitos hexadecimais significam que até 3 bits precisam ser removidos do significando, o que leva a problemas surpreendentes na precisão da aritmética de ponto flutuante. Mainframes IBM recentes admitem IEEE 754 além do formato hexa.
Adição em ponto flutuante Vamos somar os números na notação científica manualmente, para ilustrar os problemas na adição em ponto flutuante: 9,999dec × 101 + 1,610dec × 10–1. Suponha que só possamos armazenar quatro dígitos decimais do significando e dois dígitos decimais do expoente. Etapa 1. Para poder somar estes números corretamente, temos de alinhar o ponto decimal do número que possui o menor expoente. Logo, precisamos de uma forma do número menor, 1,610dec × 10-1, que combine com o expoente maior. Obtemos isso observando que existem várias representações de um número em ponto flutuante não normalizado na notação científica:
O número da direita é a versão que desejamos, pois seu expoente combina com o expoente do maior número, 9,999dec × 101. Assim, a primeira etapa desloca o significando do menor número à direita até que seu expoente corrigido combine com o do maior número. Contudo, só podemos representar quatro dígitos decimais, de modo que, após o deslocamento, o número é, na realidade:
Etapa 2. Em seguida, vem a adição dos significandos:
Etapa 2. Em seguida, vem a adição dos significandos:
A soma é 10,015dec × 101. Etapa 3. Essa soma não está na notação científica normalizada, de modo que precisamos ajustá-la:
Assim, depois da adição, podemos ter de deslocar a soma para colocá-la na forma normalizada, ajustando o expoente de acordo. Esse exemplo mostra o deslocamento para a direita, mas, se um número fosse positivo e o outro negativo, é possível que a soma tenha muitos 0s iniciais, exigindo deslocamentos à esquerda. Sempre que o expoente é aumentado ou diminuído, temos de verificar o overflow ou underflow — ou seja, temos de verificar se o expoente ainda cabe em seu campo. Etapa 4. Como pressupomos que o significando só pode ter quatro dígitos de extensão (excluindo o sinal), temos de arredondar o número. Em nosso algoritmo que aprendemos na escola, as regras truncam o número se o dígito à direita do ponto desejado estiver entre 0 e 4 e somamos 1 ao dígito se o número à direita estiver entre 5 e 9. O número
é arredondado para quatro dígitos no significando, passando para
pois o quarto dígito à direita do ponto decimal estava entre 5 e 9. Observe que, se não tivermos sorte no arredondamento, como ao somar 1 a uma sequência de 9s, a soma não pode mais ser normalizada, sendo necessário realizar a etapa 3 novamente. A Figura 3.14 mostra o algoritmo para a adição binária de ponto flutuante que acompanha este exemplo em decimal. As etapas 1 e 2 são semelhantes ao exemplo que discutimos: ajustar o significando do número com o menor expoente e depois somar os dois significandos. A etapa 3 normaliza os resultados, forçando uma verificação de overflow ou underflow. O teste de overflow e underflow na etapa 3 depende da precisão dos operandos. Lembre-se de que o padrão de todos os bits zero no expoente é reservado e usado para a representação de ponto flutuante de zero. Além disso, o padrão de todos os bits um no expoente é reservado para indicar valores e situações fora do escopo dos números de ponto flutuante normais (veja a Seção “Detalhamento” na página 223). Para o exemplo a seguir, lembre-se de que, para a precisão simples, o expoente máximo é 127, e o expoente mínimo é –126.
FIGURA 3.14 Adição de ponto flutuante. O caminho normal é executar as etapas 3 e 4 uma vez, mas se o arredondamento fizer com que a soma não fique normalizada, temos de repetir a etapa 3.
Adição de ponto flutuante em binário Exemplo Tente somar os números 0,5dec e –0,4375dec em binário usando o algoritmo da Figura 3.14.
Resposta Primeiro, vejamos a versão binária dos dois números na notação científica normalizada, supondo que mantivemos 4 bits de precisão:
Agora, seguimos o algoritmo: Etapa 1. O significando do número com o menor expoente (-1,11bin × 2-2) é deslocado para a direita até seu expoente combinar com o maior número:
Etapa 2. Some os significandos:
Etapa 3. Normalize a soma, verificando overflow ou underflow:
Como 127 ≥ +4 ≥ –126, não existe overflow ou underflow. (O expoente deslocado seria – 4 + 127 ou 123, que está entre 1 e 254, o menor e o maior expoente deslocado não reservado.) Etapa 4. Arredonde a soma:
A soma já cabe exatamente em 4 bits, de modo que não há mudança nos bits em razão do arredondamento. Essa soma é, então
Essa soma é o que esperaríamos da soma de 0,5dec a –0,4375dec. Muitos computadores dedicam o hardware para executar operações de ponto flutuante o mais rápido possível. A Figura 3.15 esboça a organização básica do hardware para a adição de ponto flutuante.
FIGURA 3.15 Diagrama de bloco de uma unidade aritmética dedicada à adição em ponto flutuante. As etapas da Figura 3.14 correspondem a cada bloco, de cima para baixo. Primeiro, o expoente de um operando é subtraído do outro usando a ALU pequena para determinar qual é maior e quanto. Essa diferença controla os três multiplexadores; da esquerda para a direita, eles selecionam o maior expoente, o significando do menor número e o significando do maior número. O menor significando é deslocado para a direita, e depois os significandos são somados usando a ALU grande. A etapa de normalização, então, desloca a soma para a esquerda ou para a direita e incrementa ou decrementa o expoente. O arredondamento, então, cria o resultado final, que pode exigir normalizando novamente para produzir o resultado definitivo.
Multiplicação em ponto flutuante Agora que já explicamos a adição em ponto flutuante, vamos experimentar a multiplicação em ponto flutuante. Começamos multiplicando os números decimais em notação científica na mão: 1,110dec × 1010 × 9,200dec × 10–5. Suponha que possamos armazenar apenas quatro dígitos do significando e dois dígitos do expoente. Etapa 1. Ao contrário da adição, calculamos o expoente do produto simplesmente somando os expoentes dos operandos:
Vamos fazer isso com os expoentes deslocados, para obtermos o mesmo resultado: 10 + 127 = 137, e – 5 + 127 = 122, então:
Esse resultado é muito grande para o campo de expoente de 8 bits, de modo que há algo faltando! O problema é com o bias, pois estamos somando os biases e também os expoentes:
De acordo com isso, para obter a soma deslocada correta quando somamos números deslocados, temos de subtrair o bias da soma:
e 5 é, na realidade, o expoente que calculamos inicialmente. Etapa 2. Em seguida, vem a multiplicação dos significandos:
Existem três dígitos à direita do ponto decimal para cada operando, de modo que o ponto decimal é colocado seis dígitos a partir da direita no significando do produto:
Supondo que só possamos manter três dígitos à direita do ponto decimal, o produto é 10,212 × 105. Etapa 3. Este produto não está normalizado, de modo que precisamos normalizá-lo:
Assim, após a multiplicação, o produto pode ser deslocado para a direita um dígito, a fim de colocá-lo no formato normalizado, somando 1 ao expoente. Nesse ponto, podemos verificar o overflow e o underflow. O underflow pode ocorrer se os dois operandos forem pequenos — ou seja, se ambos tiverem expoentes negativos grandes. Etapa 4. Consideramos que o significando tem apenas quatro dígitos de largura
Etapa 4. Consideramos que o significando tem apenas quatro dígitos de largura (excluindo o sinal), de modo que devemos arredondar o número. O número
é arredondado para quatro dígitos no significando, para
Etapa 5. O sinal do produto depende dos sinais dos operandos originais. Se forem iguais, o sinal é positivo; caso contrário, é negativo. Logo, o produto é
O sinal da soma no algoritmo de adição foi determinado pela adição dos significandos; porém, na multiplicação, o sinal do produto é determinado pelos sinais dos operandos. Mais uma vez, como mostra a Figura 3.16, a multiplicação de números binários em ponto flutuante é muito semelhante às etapas que acabamos de concluir. Começamos calculando o novo expoente do produto, somando os expoentes deslocados, subtraindo um bias para obter o resultado apropriado. Em seguida, vem a multiplicação de significandos, seguida por uma etapa de normalização opcional. O tamanho do expoente é verificado, em busca de overflow ou underflow, e depois o produto é arredondado. Se o arredondamento causar mais normalização, mais uma vez verificamos o tamanho do expoente. Finalmente, definimos o bit de sinal como 1 se os sinais dos operandos forem diferentes (produto negativo) ou como 0 se forem iguais (produto positivo).
FIGURA 3.16 Multiplicação em ponto flutuante. O caminho normal é executar as etapas 3 e 4 uma vez, mas se o arredondamento fizer com que a soma fique desnormalizada, temos de repetir a etapa 3.
Multiplicação em ponto flutuante em decimal Exemplo Vamos tentar multiplicar os números 0,5dec e –0,4375dec, usando as etapas na Figura 3.16.
Resposta Em binário, a tarefa é multiplicar 1,000bin × 2–1 por –1,110bin × 2–2. Etapa 1 Somando os expoentes sem bias:
ou então, usando a representação deslocada:
Etapa 2. Multiplicando os significandos:
O produto é 1,110000bin × 2–3, mas precisamos mantê-lo com 4 bits, de modo que é 1,110bin × 2–3. Etapa 3. Agora, verificamos o produto para ter certeza de que está normalizado e depois verificamos o expoente em busca de overflow ou underflow. O produto já está normalizado e, como 127 ≥ –3 ≥ –126, não existe overflow ou underflow. (Usando a representação deslocada, 254 ≥ 124 ≥ 1, de modo que o expoente cabe.) Etapa 4. O arredondamento do produto não causa mudança:
Etapa 5. Como os sinais dos operandos originais diferem, torne o sinal do produto negativo. Logo, o produto é
Convertendo para decimal, para verificar nossos resultados:
O produto entre 0,5dec e –0,4375dec é, na realidade, –0,21875dec.
Instruções de ponto flutuante no MIPS O MIPS admite os formatos de precisão simples e dupla do padrão IEEE 754 com estas instruções: ▪ Adição simples em ponto flutuante (add.s) e adição dupla (add.d) ▪ Subtração simples em ponto flutuante (sub.s) e subtração dupla (sub.d) ▪ Multiplicação simples em ponto flutuante (mul.s) e multiplicação dupla (mul.d) ▪ Divisão simples em ponto flutuante (div.s) e divisão dupla (div.d) ▪ Comparação simples em ponto flutuante (c.x.s) e comparação dupla (c.x.d), em que x pode ser igual (eq), diferente (neq), menor que (lt), menor ou igual (le), maior que (gt) ou maior ou igual (ge) ▪ Desvio verdadeiro em ponto flutuante (bclt) e desvio falso (bc1f) A comparação em ponto flutuante define um bit como verdadeiro ou falso, dependendo da condição de comparação, e um desvio de ponto flutuante então decide se desviará ou não, dependendo da condição. Os projetistas do MIPS decidiram acrescentar registradores de ponto flutuante separados – chamados $f0, $f1, $f2… — usados para precisão simples ou precisão dupla. Logo, eles incluíram loads e stores separados para registradores de ponto flutuante: lwc1 e swc1. Os registradores base para transferências de dados de ponto flutuante, usados para endereços, continuam sendo registradores inteiros. O código do MIPS para carregar dois números de precisão simples da memória, somá-los e depois armazenar a soma poderia se parecer com isto:
Um registrador de precisão dupla é, na realidade, um par de registradores (par
e ímpar) de precisão simples, usando o número do registrador par como seu nome. Assim, o par de registradores $f2 e $f3 de precisão simples também forma o registrador de precisão dupla chamado $f2. A Figura 3.17 resume a parte de ponto flutuante da arquitetura MIPS revelada neste capítulo, com as adições para dar suporte ao ponto flutuante mostradas em destaque. Semelhante à Figura 2.19 no Capítulo 2, mostramos a codificação dessas instruções na Figura 3.18.
FIGURA 3.17 Arquitetura de ponto flutuante do MIPS revelada até aqui.
Ver Apêndice A, Seção A.10, para obter mais detalhes. Essa informação também é encontrada na coluna 2 do Guia de Referência do MIPS, no final deste livro.
FIGURA 3.18 Codificação de instruções de ponto flutuante do MIPS. Essa notação indica o valor de um campo por linha e por coluna. Por exemplo, na parte superior da figura, lw se encontra na linha número 4 (100bin para os bits de 31-29 da instrução) e na coluna número 3 (011bin para os bits 28-26 da instrução), de modo que o valor correspondente do campo op (bits 31-26) é 100011bin. O sublinhado indica que o campo é usado em outro lugar. Por exemplo, FlPt na linha 2 e coluna 1 (op = 010001bin) está definido na parte inferior da figura. Logo, sub.f na linha 0 e coluna 1 da seção inferior significa que o campo funct (bits 5-
0 da instrução) é 000001bin e o campo op (bits 31-26) é 010001bin. Observe que o campo rs de 5 bits, especificado na parte do meio da figura, determina se a operação é de precisão simples (f = s, de modo que rs = 10000) ou precisão dupla (f = d, de modo que rs = 10001). De modo semelhante, o bit 16 da instrução determina se a instrução bc1.c testa o estado verdadeiro (bit 16 = 1 => bc1.t) ou falso (bit 16 = 1 => bc1.f). As instruções em negrito são descritas nos Capítulos 2 neste capítulo, com o Apêndice A abordando todas as instruções. Essa informação também é encontrada na coluna 2 do Guia de Referência do MIPS, no final deste livro.
Interface hardware/software Uma questão que os arquitetos de computador enfrentam no suporte à aritmética de ponto flutuante é se devem utilizar os mesmos registradores usados pelas instruções com inteiros ou acrescentar um conjunto especial de ponto flutuante. Como os programas normalmente realizam operações com inteiros e operações com ponto flutuante sobre dados diferentes, a separação dos registradores só aumentará ligeiramente o número de instruções necessárias para executar um programa. O maior impacto é criar um conjunto separado de instruções de transferência de dados para mover dados entre os registradores de ponto flutuante e a memória. Os benefícios dos registradores de ponto flutuante separados são: a existência do dobro dos registradores sem utilizar mais bits no formato da instrução, ter o dobro da largura de banda de registrador, com conjuntos de registradores separados para inteiros e números de ponto flutuante, e ser capaz de personalizar registradores para ponto flutuante; por exemplo, alguns computadores convertem todos os operandos dimensionados nos registradores para um único formato interno.
Compilando um programa C de ponto flutuante em código assembly do MIPS Exemplo Vamos converter uma temperatura em Fahrenheit para Celsius:
Considere que o argumento de ponto flutuante fahr seja passado em $f12 e o resultado deva ficar em $f0. (Ao contrário dos registradores inteiros, o registrador de ponto flutuante 0 pode conter um número.) Qual é o código assembly do MIPS?
Resposta Consideramos que o compilador coloca as três constantes de ponto flutuante na memória para serem alcançadas facilmente por meio do ponteiro global $gp. As duas primeiras instruções carregam as constantes 5.0 e 9.0 nos registradores de ponto flutuante:
Depois, elas são divididas para que se obtenha a fração 5.0/9.0:
(Muitos compiladores dividiriam 5.0 por 9.0 durante a compilação e guardariam uma única constante 5.0/9.0 na memória, evitando, assim, a divisão em tempo de execução.) Em seguida, carregamos a constante 32.0 e depois a subtraímos de fahr ($f12):
Finalmente, multiplicamos os dois resultados intermediários, colocando o
produto em $f0 como resultado de retorno, e depois retornamos:
Agora, vamos realizar operações de ponto flutuante em matrizes, código comumente encontrado em programas científicos.
Compilando um procedimento em C de ponto flutuante com matrizes bidimensionais no MIPS Exemplo A maioria dos cálculos de ponto flutuante é realizada com precisão dupla. Vamos realizar uma multiplicação de matrizes C = C + A * B. Isso normalmente é chamado de DGEMM, de Double precision, General Matrix Multiply. Veremos versões de DGEMM novamente na Seção 3.8 e, mais adiante, nos Capítulos 4, 5 e 6. Vamos supor que C, A e B sejam matrizes quadradas com 32 elementos em cada dimensão.
Os endereços iniciais do array são parâmetros, de modo que estão em $a0, $a1 e $a2. Suponha que as variáveis inteiras estejam em $s0, $s1 e $s2, respectivamente. Qual é o código assembly do MIPS para o corpo do procedimento?
Resposta
Resposta Observe que c[i][j] é usado no loop mais interno. Como o índice do loop é k, o índice não afeta c[i][j], de modo que podemos evitar a leitura e o armazenamento de c[i][j] a cada iteração. Em vez disso, o compilador lê c[i][j] em um registrador fora do loop, acumula a soma dos produtos de a[i][k] e b[k][j] nesse mesmo registrador, e depois armazena a soma em c[i][j], ao terminar o loop mais interno. Mantemos o código mais simples, usando as pseudoinstruções em assembly li (que carrega uma constante em um registrador), e l.d e s.d (que o montador transforma em um par de instruções de transferência de dados, lwc1 ou swc1, para um par de registradores de ponto flutuante). O corpo do procedimento começa salvando o valor de término do loop (32) em um registrador temporário e depois inicializando as três variáveis do loop for:
Para calcular o endereço de c[i][j] precisamos saber como um array bidimensional de 32 × 32 é armazenado na memória. Como você poderia esperar, seu layout é como se houvesse 32 arrays unidimensionais, cada um com 32 elementos. Assim, a primeira etapa é pular os i “arrays unidimensionais” ou linhas, para obter a que desejamos. Assim, multiplicamos o índice da primeira dimensão pelo tamanho da linha, 32. Como 32 é uma potência de 2, podemos usar um deslocamento em seu lugar:
Agora, acrescentamos o segundo índice para selecionar o elemento j da linha desejada:
Para transformar essa soma em um índice de bytes, multiplicamos pelo tamanho de um elemento da matriz em bytes. Como cada elemento tem 8 bytes para a precisão dupla, podemos deslocar à esquerda por 3:
Em seguida, somamos essa soma ao endereço base de c, dando o endereço de c[i][j], e depois carregamos o número de precisão dupla c[i][j] em $f4:
As cinco instruções a seguir são praticamente idênticas às cinco últimas: calcular o endereço e depois ler o número de precisão dupla b[k][j].
De modo semelhante, as cinco instruções a seguir são como as cinco últimas: calcular o endereço e depois carregar o número de precisão dupla a[i][k].
Agora que carregamos todos os dados, finalmente estamos prontos para
realizar algumas operações em ponto flutuante! Multiplicamos os elementos de a e b localizados nos registradores $f18 e $f16, e depois acumulamos a soma em $f4.
O bloco final incrementa o índice k e retorna se o índice não for 32. Se for 32, ou seja, o final do loop mais interno, precisamos armazenar em x[i][j] a soma acumulada em $f4.
***** De modo semelhante, essas quatro últimas instruções incrementam a variável de índice do loop do meio e do loop mais externo, voltando no loop se o índice não for 32 e saindo se o índice for 32.
A Figura 3.22, mais adiante, mostra o código em linguagem assembly x86 para uma versão ligeiramente diferente da DGEMM da Figura 3.21.
Detalhamento O layout do array discutido no exemplo, chamado ordem linhas primeiro, é usado pela linguagem C e muitas outras linguagens de programação. Fortran,
por sua vez, usa a ordem colunas primeiro, pela qual o array é armazenado coluna por coluna.
Detalhamento Somente 16 dos 32 registradores de ponto flutuante do MIPS puderam ser usados originalmente para operações de precisão simples: $f0, $f2, $f4, …, $f30. A precisão dupla é calculada usando pares desses registradores. Os registradores de ponto flutuante com números ímpares só foram usados para carregar e armazenar a metade direita dos números de ponto flutuante de 64 bits. MIPS-32 acrescentou l.d e s.d ao conjunto de instruções. MIPS-32 também acrescentou versões “simples emparelhadas” de todas as instruções de ponto flutuante, em que uma única instrução resulta em duas operações paralelas de ponto flutuante sobre dois operandos de 32 bits dentro de registradores de 64 bits. Por exemplo, add.ps $f0, $f2, $f4 é equivalente a add.s $f0, $f2, $f4, seguido por add.s $f1, $f3, $f5.
Detalhamento Outro motivo para que os registradores inteiros e de ponto flutuante sejam separados é que os microprocessadores na década de 1980 não possuíam transistores suficientes para colocar a unidade ponto flutuante no mesmo chip da unidade de inteiros. Logo, a unidade de ponto flutuante, incluindo os registradores de ponto flutuante, opcionalmente estava disponível como um segundo chip. Esses chips aceleradores opcionais são chamados coprocessadores e explicam o acrônimo para os loads de ponto flutuante no MIPS: lwc1 significa “load word to coprocessor 1” (“leia uma palavra para o coprocessador 1”), que é a unidade de ponto flutuante. (O coprocessador 0 trata da memória virtual, descrita no Capítulo 5.) Desde o início da década de 1990, os microprocessadores têm integrado o ponto flutuante (e praticamente tudo o mais) no chip, e, por isso, o termo coprocessador reúne acumulador e memória.
Detalhamento Conforme mencionamos na Seção 3.4, acelerar a divisão é mais complicado do que a multiplicação. Além de SRT, outra técnica para aproveitar um
multiplicador rápido é a iteração de Newton, na qual a divisão é redefinida como a localização do zero de uma função para encontrar a recíproca 1/c, que é então multiplicada pelo outro operando. As técnicas de iteração não podem ser arredondadas corretamente sem o cálculo de muitos bits extras. Um chip TI soluciona esse problema, calculando uma recíproca de precisão extra.
Detalhamento Java abarca o padrão IEEE 754 por nome em sua definição dos tipos de dados e operações de ponto flutuante Java. Assim, o código no primeiro exemplo poderia muito bem ter sido gerado para um método de classe que convertesse graus Fahrenheit em Celsius. O segundo exemplo utiliza múltiplos arrays dimensionais, que não são admitidos explicitamente em Java. O Java permite arrays de arrays, mas cada array pode ter seu próprio tamanho, ao contrário dos arrays multidimensionais em C. Como os exemplos no Capítulo 2, uma versão em Java desse segundo exemplo exigiria muito código de verificação para os limites de array, incluindo um novo cálculo de tamanho no final da linha. Ela também precisaria verificar se a referência ao objeto não é nula.
Aritmética de precisão Ao contrário dos inteiros, que podem representar exatamente cada número entre o menor e o maior, os números de ponto flutuante, em geral, são aproximações para um número que realmente não representam. O motivo é que existe uma variedade infinita de números reais entre, digamos, 0 e 1, porém não mais do que 253 podem ser representados com exatidão em ponto flutuante de precisão dupla. O melhor que podemos fazer é utilizar a representação de ponto flutuante próxima ao número real. Assim, o padrão IEEE 754 oferece vários modos de arredondamento para permitir que o programador selecione a aproximação desejada. O arredondamento parece muito simples, mas arredondar com precisão exige que o hardware inclua bits extras no cálculo. Nos exemplos anteriores, fomos vagos com relação ao número de bits que uma representação intermediária pode ocupar, mas, claramente, se cada resultado intermediário tivesse de ser truncado ao número de dígitos exato, não haveria oportunidade para arredondar. O IEEE 754, portanto, sempre mantém dois bits extras à direita durante adições
intermediárias, chamados guarda e arredondamento, respectivamente. Vamos fazer um exemplo decimal para ilustrar o valor desses dígitos extras.
guarda O primeiro dos dois bits extras mantidos à direita durante os cálculos intermediários de números de ponto flutuante, usados para melhorar a precisão do arredondamento.
arredondamento Método para fazer com que o resultado de ponto flutuante intermediário se encaixe no formato de ponto flutuante; o objetivo normalmente é encontrar o número mais próximo que pode ser representado no formato.
Arredondando com dígitos de guarda Exemplo Some 2,56dec × 100 a 2,34dec × 102, supondo que temos três dígitos decimais significativos. Arredonde para o número decimal mais próximo com três dígitos decimais significativos, primeiro com dígitos de guarda e arredondamento, e depois sem eles.
Resposta Primeiro, temos de deslocar o número menor para a direita, a fim de alinhar os expoentes, de modo que 2,56dec × 100 torna-se 0,0256dec × 102. Como temos dígitos de guarda e arredondamento, podemos representar os dois dígitos menos significativos quando alinharmos os expoentes. O dígito de guarda mantém 5 e o dígito de arredondamento mantém 6. A soma é
Assim, a soma é 2,3656dec × 102. Como temos dois dígitos para arredondar, queremos que os valores de 0 a 49 arredondem para baixo e de 51 a 99 para cima, com 50 sendo o desempate. Arredondar a soma para cima com três dígitos significativos gera 2,37dec × 102. Fazer isso sem dígitos de guarda e arredondamento remove dois dígitos do cálculo. A nova soma é, então,
A resposta é 2,36dec × 102, arredondando para baixo em um comparativo com o último dígito da soma anterior. Como o pior caso para o arredondamento seria quando o número real está a meio caminho entre duas representações de ponto flutuante, a precisão no ponto flutuante normalmente é medida em termos do número de bits com erro nos bits mais significativos do significando; a medida é denominada número de unidades na última casa ou ulp (units in the last place). Se o número ficou defasado em 2 nos bits menos significativos, ele estaria defasado por 2 ulps. Desde que não haja qualquer overflow, underflow ou exceções de operação inválida, o IEEE 754 garante que o computador utiliza o número que está dentro de meia ulp.
unidades na última casa (ulp) O número de bits com erro nos bits menos significativos do significando entre o número real e o número que pode ser representado.
Detalhamento Embora o exemplo anterior, na realidade, precisasse apenas de um dígito extra, a multiplicação pode precisar de dois. Um produto binário pode ter um bit 0 inicial; logo, a etapa de normalização precisa deslocar o produto 1 bit à esquerda. Isso desloca o dígito de guarda para o bit menos significativo do produto, deixando o bit de arredondamento para ajudar no arredondamento mais preciso do produto. O IEEE 754 tem quatro modos de arredondamento: sempre arredondar para cima (para +∞), sempre arredondar para baixo (para –∞), truncar e arredondar para o par mais próximo. O modo final determina o que fazer se o número estiver exatamente no meio. A Receita Federal americana sempre arredonda 0,50 dólares para cima, possivelmente beneficiando a Receita. Um modo mais imparcial seria arredondar para cima, nesse caso, na metade do tempo e arredondar para baixo na outra metade. O IEEE 754 diz que, se o bit menos significativo retido em um caso de meio do caminho for ímpar, some um; se for par, trunque. Esse método sempre cria um 0 no bit menos significativo no caso de desempate, dando nome ao arredondamento. Esse modo é o mais utilizado, e o único que o Java admite. O objetivo dos bits de arredondamento extras é permitir que o computador obtenha os mesmos resultados, como se os resultados intermediários fossem calculados para precisão infinita e depois arredondados. Para auxiliar nesse objetivo e arredondar para o par mais próximo, o padrão possui um terceiro bit além do bit de guarda e arredondamento; ele é definido sempre que existem bits diferentes de zero à direita do bit de arredondamento. Esse sticky bit permite que o computador veja a diferença entre 0,50 … 00dec e 0,50 … 01dec ao arredondar. O sticky bit pode ser definido, por exemplo, durante a adição, quando o menor número é deslocado para a direita. Suponha que somemos 5,01dec × 10-1 a 2,34dec × 102 no exemplo anterior. Mesmo com os bits de guarda e arredondamento, estaríamos somando 0,0050 a 2,34, com uma soma de 2,3450. O sticky bit seria definido, porque existem bits diferentes de zero à
direita. Sem o sticky bit para lembrar se quaisquer 1s foram deslocados, consideraríamos que o número é igual a 2.345000…00 e arredondaríamos para o par mais próximo de 2,34. Com o sticky bit para lembrar que o número é maior do que 2,345000…00, arredondaríamos para 2,35.
sticky bit Um bit usado no arredondamento além dos bits de guarda e arredondamento, definido sempre que existem bits diferentes de zero à direita do bit de arredondamento.
Detalhamento As arquiteturas PowerPC, SPARC64, AMD SSE5 e Intel AVX oferecem uma única instrução que realiza multiplicação e adição sobre três registradores: a = a + (b × c). Obviamente, essa instrução permite um desempenho de ponto flutuante potencialmente mais alto para essa operação comum. Igualmente importante é que, em vez de realizar dois arredondamentos — depois da multiplicação e após a adição — que aconteceria com instruções separadas, a instrução de multiplicação-adição pode realizar um único arredondamento após a adição, o que aumenta a precisão da multiplicação-adição. Essas operações com um único arredondamento são chamadas multiplicaçãoadição fundida. Isso foi acrescentado no padrão IEEE 754-2008 revisado.
multiplicação adição fundida Uma instrução de ponto flutuante que realiza uma multiplicação e uma adição, mas arredonda apenas depois da adição.
Resumo A próxima seção “Colocando em perspectiva” reforça o conceito de programa armazenado do Capítulo 2; o significado da informação não pode ser determinado simplesmente examinando-se os bits, pois os mesmos bits podem representar uma série de objetos. Esta seção mostra que a aritmética computacional é finita e, assim, pode não combinar com a aritmética natural. Por exemplo, a representação de ponto flutuante do padrão IEEE 754
é quase sempre uma aproximação do número real. Os sistemas computacionais precisam ter o cuidado de minimizar essa lacuna entre a aritmética computacional e a aritmética no mundo real, e os programadores às vezes precisam estar cientes das implicações dessa aproximação.
Colocando em perspectiva Padrões de bits não possuem significado inerente. Eles podem representar inteiros com sinal, inteiros sem sinal, números de ponto flutuante, instruções e assim por diante. O que é representado depende da instrução que opera sobre os bits na palavra. A principal diferença entre os números no computador e os números no mundo real é que os números no computador possuem tamanho limitado e, por isso, uma precisão limitada; é possível calcular um número muito grande ou muito pequeno para ser representado em uma palavra. Os programadores precisam se lembrar desses limites e escrever programas de acordo. Tipo C
Tipo Java
Transferências de dados
Operações
interface
int
lw, sw, lui
addu, addiu, subu, mult, div, AND, ANDi, OR, ORi, NOR, slt, slti
unsigned int
—
lw, sw, lui
addu, addiu, subu, mult, divu, AND, ANDi, OR, ORi, NOR, sltu, sltiu
char
—
lb, sb, lui
add, addi, sub, mult, div, AND, ANDi, OR, ORi, NOR, slt, slti
—
char
lh, sh, lui
addu, addiu, subu, multu, divu, AND, ANDi, OR, ORi, NOR, sltu, sltiu
float
float
lwc1, swc1
add.s, sub.s, mult.s, div.s, c.eq.s, c.lt.s, c.le.s
double
double
l.d, s.d
add.d, sub.d, mult.d, div.d, c.eq.d, c.lt.d, c.le.d
Interface hardware/software No capítulo anterior, apresentamos as classes de armazenamento da linguagem de programação C (veja a Seção “Interface Hardware/Software” da Seção 2.7). A tabela anterior mostra alguns dos tipos de dados C e Java junto com as instruções de transferência de dados MIPS e instruções que operam sobre aqueles tipos que aparecem aqui e no Capítulo 2. Observe que Java omite inteiros sem sinal.
Verifique você mesmo O padrão IEEE 754-2008 revisado acrescentou o formato de ponto flutuante de 16 bits com 5 bits de expoente. Qual seria o intervalo provável de números que ele poderia representar? 1. 1,0000 00 × 20 a 1,1111 1111 11 × 231, 0 2. ±1,0000 0000 0 × 2-14 a ±1.1111 1111 1 × 215, ±0, ±∞, NaN 3. ±1,0000 0000 00 × 2-14 a ±1.1111 1111 11 × 215, ±0, ±∞, NaN 4. ±1,0000 0000 00 × 2-15 a ±1.1111 1111 11 × 214, ±0, ±∞, NaN
Detalhamento Para acomodar comparações que possam incluir NaNs, o padrão inclui ordenada e não ordenada como opções para comparações. Logo, o conjunto de instruções MIPS inteiro possui muitos tipos de comparações para dar suporte a NaNs. (Java não admite comparações não ordenadas.) Na tentativa de compactar cada bit de precisão de uma operação de ponto flutuante, o padrão permite que alguns números sejam representados de forma não normalizada. Em vez de ter uma lacuna entre 0 e o menor número normalizado, o IEEE permite números não normalizados (também conhecidos como denormais ou subnormais). Eles têm o mesmo expoente que zero, mas um significando diferente de zero. Eles permitem que um número diminua no significado até se tornar 0, chamado underflow gradual. Por exemplo, o menor número normalizado positivo de precisão simples é
mas o menor número não normalizado de precisão simples é
Para a precisão dupla, a lacuna denormal vai de 1,0 × 2-1022 a 1,0 × 2-1074. A possibilidade de um operando ocasional não normalizado tem dado dores de cabeça aos projetistas de ponto flutuante que estejam tentando criar
unidades de ponto flutuante velozes. Logo, muitos computadores causam uma exceção se um operando for não normalizado, permitindo que o software complete a operação. Embora as implementações de software sejam perfeitamente válidas, seu menor desempenho diminuiu a popularidade dos denormais no software de ponto flutuante portável. Além disso, se os programadores não esperarem os denormais, seus programas poderão surpreendê-los.
3.6. Paralelismo e aritmética computacional: paralelismo subword Como todo microprocessador desktop, por definição, tem suas próprias telas gráficas, quando a quantidade de transistores aumentou, foi inevitável que seria acrescentado suporte para operações gráficas. Muitos sistemas gráficos usavam inicialmente 8 bits para representar cada uma das três cores primárias, mais 8 bits para um local de um pixel. O acréscimo de alto-falantes e microfones para teleconferência e videogames sugeriu o suporte também para o som. Amostras de áudio precisam de mais de 8 bits de precisão, mas 16 bits são suficientes. Todo microprocessador possui suporte especial, de modo que bytes e halfwords ocupam menos espaço quando armazenados na memória (Seção 2.9), mas, devido à pouca frequência das operações aritméticas sobre esses tamanhos de dados nos programas típicos para inteiros, houve pouco suporte além de transferências de dados. Os arquitetos reconheceram que muitas aplicações gráficas e de áudio realizariam a mesma operação sobre vetores desses dados. Partindo as cadeias de carry dentro de um somador de 128 bits, um processador poderia usar o paralelismo para realizar operações simultâneas sobre vetores curtos de dezesseis operandos de 8 bits, oito operandos de 16 bits, quatro operandos de 32 bits ou dois operandos de 64 bits. O custo desses somadores partidos era muito pequeno.
Dado que o paralelismo ocorre dentro de uma word larga, as extensões são classificadas como paralelismo subword. Isso também é classificado sob o nome mais genérico de paralelismo em nível de dados. Eles também foram chamados de vetor ou SIMD, de Single Instruction, Multiple Data (única instrução, múltiplos dados — Seção 6.6). A popularidade crescente das aplicações multimídia levou a instruções aritméticas que oferecem suporte a operações mais estreitas, que podem facilmente operar em paralelo. Por exemplo, o ARM acrescentou mais de 100 instruções na extensão da instrução de multimídia NEON, para dar suporte ao paralelismo subword, que pode ser usado ou com ARMv7 ou com ARMv8. Ele acrescentou 256 bytes de novos registradores para o NEON, que podem ser vistos como 32 registradores com 8 bytes de largura ou 16 registradores com 16 bytes de largura. O NEON tem suporte para todos os tipos de dados de subword que você possa imaginar, exceto números de ponto flutuante com 64 bits: ▪ Inteiros com e sem sinal, com 8 bits, 16 bits, 32 bits e 64 bits ▪ Número de ponto flutuante com 32 bits A Figura 3.19 oferece um resumo das instruções NEON básicas.
FIGURA 3.19 Resumo das instruções NEON do ARM para paralelismo subword. Usamos as chaves { } para mostrar variações opcionais das operações básicas: {S8,U8,8} representam inteiros de 8 bits com e sem sinal ou dados de 8 bits onde o tipo não importa, dos quais 16 cabem em um registrador de 128 bits; {S16,U16,16} representam inteiros de 16 bits com e sem sinal ou dados de 16 bits sem tipo, dos quais 8 cabem em um registrador de 128 bits; {S32,U32,32} representam inteiros de 32 bits com e sem sinal ou dados de 32 bits sem tipo, dos quais 4 cabem em um registrador de 128 bits; {S64,U64,64} representam inteiros de 64 bits com e sem sinal ou dados de 64 bits sem tipo, dos quais 2 cabem em um registrador de 128 bits; {F32} representa números de ponto flutuante de 32 bits com e sem sinal, dos quais 4 cabem em um registrador de 128 bits. Vector Load carrega uma estrutura com n elementos da memória para 1, 2, 3 ou 4 registradores NEON. Ele carrega uma única estrutura de n elementos para uma pista (Seção 6.6), e os elementos do registrador que não são carregados ficam inalterados. Vector Store escreve uma estrutura de n elementos na memória a partir de 1, 2, 3 ou 4 registradores NEON.
Detalhamento Além de inteiros com e sem sinal, o ARM inclui o formato de “ponto fixo” de quatro tamanhos, chamados I8, I16, I32 e I64, dos quais 16, 8, 4 e 2 cabem em um registrador de 128 bits, respectivamente. Uma parte do ponto fixo é para a fração (à direita do ponto binário) e o restante dos dados é a parte inteira (à esquerda do ponto binário). O local do ponto decimal fica a critério do software. Muitos processadores ARM não possuem hardware de ponto flutuante e, portanto, as operações de ponto flutuante precisam ser realizadas
por rotinas de biblioteca. A aritmética de ponto fixo pode ser significativamente mais rápida que as rotinas de ponto flutuante no software, mas dão mais trabalho para o programador.
3.7. Vida real: Extensões SIMD streaming e extensões avançadas de vetor no x86 As instruções MMX (MultiMedia eXtension) e SSE (Streaming SIMD Extension) originais para o x86 incluíam operações semelhantes àquelas encontradas no ARM NEON. O Capítulo 2 observa que, em 2001, a Intel acrescentou 144 instruções à sua arquitetura, incluindo registradores e operações de ponto flutuante com precisão dupla. Isso inclui oito registradores de 64 bits que podem ser usados como operandos de ponto flutuante. A AMD expandiu o número para 16 registradores, chamados XMM, como parte do AMD64, que a Intel passou a chamar de EM64T para seu uso. A Figura 3.20 resume as instruções SSE e SSE2.
FIGURA 3.20 As instruções de ponto flutuante SSE/SSE2 do x86. xmm significa que um operando é um registrador SSE2 de 128 bits, e mem/xmm significa que o outro operando está na memória ou é um registrador SSE2. Usamos as chaves { } para mostrar variações opcionais das operações básicas: {SS} representa ponto flutuante de precisão Scalar Single ou um operando de 32 bits em um registrador de 128 bits; {PS} representa ponto flutuante de precisão Packed Single ou quatro operandos de 32 bits em um registrador de 128 bits; {SD} representa ponto flutuante de precisão Scalar Double ou um
operando de 64 bits em um registrador de 128 bits; {PD} representa ponto flutuante de precisão Packed Double ou dois operandos de 64 bits em um registrador de 128 bits; {A} significa que o operando de 128 bits é alinhado na memória; {U} significa que o operando de 128 bits é desalinhado na memória; {H} significa mover a metade alta (High) do operando de 128 bits; e {L} significa mover a metade baixa (Low) do operando de 128 bits.
Além de manter um número de precisão simples ou de precisão dupla em um registrador, a Intel permite que vários operandos de ponto flutuante sejam colocados em um único registrador SSE2 de 128 bits: quatro de precisão simples e dois de precisão dupla. Assim, os 16 registradores de ponto flutuante para o SSE2 possuem na realidade 128 bits de largura. Se os operandos podem ser organizados na memória como dados alinhados em 128 bits, então as transferências de dados de 128 bits podem carregar e armazenar vários operandos por instrução. Esse formato de ponto flutuante compactado é aceito por operações aritméticas que podem operar simultaneamente sobre quatro números de precisão simples (PS) ou dois de precisão dupla (PD). Em 2011, a Intel dobrou a largura dos registradores novamente, agora denominados YMM, com Advanced Vector Extensions (AVX). Assim, uma única operação agora pode especificar oito operações de ponto flutuante com 32 bits ou quatro operações de ponto flutuante com 64 bits. As instruções SSE e SSE2 legadas, agora operam sobre os 128 bits mais baixos dos registradores YMM. Assim, para passar de operações de 128 bits para 256 bits, você prefixa a letra “v” (de vetor) na frente das operações em linguagem assembly SSE2 e depois usa os nomes de registrador YMM no lugar do nome do registrador XMM. Por exemplo, a instrução SSE2 para realizar duas multiplicações de ponto flutuante com 64 bits
torna-se
que agora produz quatro multiplicações de ponto flutuante com 64 bits.
Detalhamento AVX também acrescentou três instruções de endereço ao x86. Por exemplo, vaddpd agora pode especificar
em vez da versão padrão com dois endereços
(Ao contrário do MIPS, o destino está à direita no x86.) Três endereços podem reduzir o número de registradores e instruções necessárias para um cálculo.
3.8. Mais rápido: Paralelismo subword e multiplicação matricial Para demonstrar o impacto do paralelismo subword sobre o desempenho, vamos executar o mesmo código no Intel Core i7, primeiro sem AVX e depois com ele. A Figura 3.21 é uma versão não otimizada de uma multiplicação matricial, escrita em C. Como vimos na Seção 3.5, esse programa normalmente é chamado DGEMM, que significa Double precision GEneral Matrix Multiply. A partir desta edição, incluímos uma nova seção, intitulada “Mais rápido”, para demonstrar o benefício no desempenho da adaptação do software ao hardware subjacente, neste caso, a versão Sandy Bridge do microprocessador Intel Core i7. Esta nova seção nos Capítulos 3, 4, 5 e 6 melhorará, de forma incremental, o desempenho da DGEMM usando as ideias que cada capítulo introduz.
FIGURA 3.21 Versão não otimizada de uma multiplicação matricial com precisão dupla, comumente conhecida como DGEMM, de Double-precision GEneral Matrix Multiply. Como estamos passando a dimensão da matriz como o parâmetro n, esta versão de DGEMM usa versões unidimensionais das matrizes C, A e B, e a aritmética de endereço para obter um melhor desempenho, em vez de usar os arrays bidimensionais, mais intuitivos, que vimos na Seção 3.5. Os comentários nos lembram dessa notação mais intuitiva.
A Figura 3.22 mostra a saída na linguagem assembly x86 para o loop mais interno da Figura 3.21. As cinco instruções de ponto flutuante começam com um v, como as instruções AVX, mas observe que elas usam os registradores XMM, em vez de YMM, e incluem sd no nome, que significa precisão dupla escalar (scalar double). Veremos em breve as instruções paralelas de subword.
FIGURA 3.22 A linguagem assembly x86 para o corpo dos loops aninhados gerados pela compilação do código C não otimizado na Figura 3.21. Embora esteja lidando com apenas 64 bits de dados, o compilador usa a versão AVX das instruções, em vez do SSE2, provavelmente para que possa usar três endereços por instruções em vez de dois (veja o Detalhamento na Seção 3.7).
Embora os escritores de compilador eventualmente possam ser capazes de produzir rotineiramente um código de alta qualidade, que usa instruções AVX do x86, por enquanto precisamos “trapacear” usando intrínsecos C, que praticamente informam ao compilador como produzir exatamente um código bom. A Figura 3.23 mostra a versão aperfeiçoada da Figura 3.21, para a qual o compilador Gnu C produz código AVX. A Figura 3.24 mostra o código x86 anotado que é a saída da compilação usando gcc com o nível de otimização –O3.
FIGURA 3.23 Versão C otimizada de DGEMM usando detalhes do C para gerar as instruções paralelas de subword AVX para o x86. A Figura 3.24 mostra a linguagem assembly produzida pelo compilador para o loop mais interno.
FIGURA 3.24 A linguagem assembly x86 para o corpo dos loops aninhados gerados pela compilação do código C
otimizado da Figura 3.23. Observe as semelhanças com a Figura 3.22, sendo a principal diferença que as cinco operações de ponto flutuante agora estão usando registradores YMM e usando as versões pd das instruções para precisão dupla paralela, em vez da versão sd para a precisão dupla escalar.
A declaração na linha 6 da Figura 3.23 usa o tipo de dado __m256d, que diz ao compilador que a variável manterá 4 valores de ponto flutuante com precisão dupla. O intrínseco _mm256_load_pd() também na linha 6 usa instruções AVX para carregar 4 números de ponto flutuante com precisão dupla em paralelo (_pd) da matriz C para c0. O cálculo de endereço C+i+j* n na linha 6 representa o elemento C[i + j * n]. Simetricamente, a etapa final na linha 11 usa o intrínseco _mm256_store_pd() para armazenar 4 números de ponto flutuante com precisão dupla a partir de c0 para a matriz C. Ao percorrermos 4 elementos em cada iteração, o loop for mais externo, na linha 4, incrementa i de 4, em vez de 1 na linha 3 da Figura 3.21. Dentro dos loops, na linha 9, primeiro carregamos quatro elementos de A novamente usando _mm256_load_pd(). Para multiplicar esses elementos por um elemento de B, na linha 10, primeiro usamos o intrínseco _mm256_broadcast_sd(), que faz quatro cópias idênticas do número escalar de precisão dupla — neste caso, um elemento de B — em um dos registradores YMM. Depois, usamos _mm256_mul_pd() na linha 9 para multiplicar os quatro resultados de precisão dupla em paralelo. Por fim, _mm256_add_pd() na linha 8 soma os quatro produtos às quatro somas em c0. A Figura 3.24 mostra o código x86 resultante, produzido pelo compilador para o corpo dos loops mais internos. Você pode ver as cinco instruções AVX — todas começando com v e quatro das cinco usando pd de precisão dupla paralela — que correspondem aos intrínsecos C mencionados acima. O código é muito semelhante ao da Figura 3.22 anterior: ambos usam 12 instruções, as instruções com inteiros são quase idênticas (mas com registradores diferentes) e as diferenças na instrução de ponto flutuante geralmente são apenas a passagem de scalar double (sd) usando registradores XMM para parallel double (pd) com registradores YMM. A única exceção é a linha 4 da Figura 3.24. Cada elemento de A precisa ser multiplicado por um elemento de B. Uma solução é colocar quatro cópias idênticas do elemento B de 64 bits lado a lado dentro do registrador YMM de 256 bits, que é exatamente o que a instrução vbroadcastsd faz.
Para matrizes com dimensões de 32 por 32, o DGEMM não otimizado na Figura 3.21 roda a 1,7 GigaFLOPS (FLoating point Operations Per Second) em um núcleo de um Intel Core i7 (Sandy Bridge) a 2,6 GHz. O código otimizado na Figura 3.23 funciona a 6,4 GigaFLOPS. A versão AVX é 3,85 vezes mais rápida, o que é muito próximo do fator de aumento 4,0 que você poderia esperar com a execução de 4 vezes mais operações de cada vez usando o paralelismo subword.
Detalhamento Como dissemos no Detalhamento na Seção 1.6, a Intel oferece o modo Turbo, que roda temporariamente a uma taxa de clock mais alta até que o chip se esquente muito. Esse Intel Core i7 (Sandy Bridge) pode aumentar de 2,6 GHz para 3,3 GHz no modo Turbo. Os resultados acima são com o modo Turbo desativado. Se o ativarmos, melhoramos todos os resultados pelo aumento na taxa de clock de 3,3/2,6 = 1,27 a 2,1 GFLOPS para DGEMM não otimizado e 8,1 GFLOPS com AVX. O modo Turbo funciona particularmente bem quando se usa apenas um único núcleo de um chip de oito núcleos, como neste caso, pois permite que o único núcleo use muito mais do que sua quota justa de potência, já que os outros núcleos estão ociosos.
3.9. Falácias e armadilhas Assim, a matemática pode ser definida como o assunto em que nunca sabemos do que estamos falando, nem se o que estamos dizendo é verdadeiro. Bertrand Russell, Recent Words on the Principles of Mathematics, 1901 As falácias e armadilhas aritméticas geralmente advêm da diferença entre a precisão limitada da aritmética computacional e da precisão ilimitada da aritmética natural. Falácia: assim como a instrução de deslocamento à esquerda pode substituir uma multiplicação de inteiros por uma potência de 2, um deslocamento à direita é o mesmo que uma divisão de inteiros por uma potência de 2. Lembre-se de que um número binário x, em que xi significa o bit na posição i, representa o número
Deslocar os bits de x para a direita de n bits pareceria ser o mesmo que dividir por 2n. E isso é verdade para inteiros sem sinal. O problema é com os inteiros com sinal. Por exemplo, suponha que queremos dividir –5dec por 4dec; o quociente seria –1dec. A representação no complemento de dois para –5dec é
De acordo com essa falácia, deslocar para a direita por dois deverá dividir por 4dec (22):
Com um 0 no bit de sinal, esse resultado claramente está errado. O valor criado pelo deslocamento à direita é, na realidade, 1.073.741.822dec, e não –1dec. Uma solução seria ter um deslocamento aritmético à direita, que estende o bit de sinal, em vez de colocar 0s à esquerda num deslocamento à direita. Um deslocamento aritmético de 2 bits para a direita de –5dec produz
O resultado é –2dec, em vez de –1dec; próximo, mas não podemos comemorar. Armadilha: a adição de ponto flutuante não é associativa. A associatividade se mantém para uma sequência de adições de inteiros em complemento de dois, mesmo que o cálculo exceda. Infelizmente, como os números de ponto flutuante são aproximações dos números reais, e como a aritmética computacional tem precisão limitada, isso não é verdade para os números de ponto flutuante. Devido a grande faixa de números que podem ser representados em ponto flutuante, ocorrem problemas quando se somam dois números grandes de sinais opostos, mais um número pequeno. Por exemplo, vejamos se c + (a + b) = (c + a) + b. Suponha que c = –1,5dec × 1038, a = 1,5dec × 1038, e b = 1,0, e que estes sejam todos números de precisão simples.
Como os números de ponto flutuante possuem precisão limitada e resultam em aproximações dos resultados reais, 1,5dec × 1038 é tão maior que 1,0dec, que 1,5dec × 1038 + 1,0 ainda é 1,5dec × 1038. É por isso que a soma de c, a e b é 0,0 ou 1,0, dependendo da ordem das adições de ponto flutuante, de modo que c + (a + b) ≠ (c + a) + b. Portanto, a adição de ponto flutuante não é associativa. Falácia: as estratégias de execução paralela que funcionam para tipos de dados inteiros também funcionam para tipos de dados de ponto flutuante. Os programas normalmente têm sido escritos primeiro para executarem sequencialmente antes de simultaneamente, de modo que uma pergunta natural é “as duas versões geram a mesma resposta?”. Se a resposta for não, você pode considerar que existe um defeito na versão paralela, que precisa ser localizado. Essa técnica considera que a aritmética do computador não afeta os resultados quando passa de sequencial para paralelo. Ou seja, se você tivesse de somar um milhão de números, obteria os mesmos resultados usando 1 processador ou 1.000 processadores. Essa suposição continua para inteiros no complemento de dois, pois a adição de inteiros é associativa. Infelizmente, como a adição de ponto flutuante não é associativa, a suposição não é mantida. Uma versão mais irritante dessa armadilha ocorre em um computador paralelo, em que o escalonador do sistema operacional pode usar um número diferente de processadores, dependendo do que outros programas estão executando em um computador paralelo. O programador paralelo desavisado pode se confundir com seu programa obtendo respostas ligeiramente diferentes toda vez que for executada exatamente com o mesmo código e entrada idêntica, pois o número variável de processadores em cada execução faria com que as somas de ponto flutuante fossem calculadas em diferentes ordens. Por causa desse dilema, os programadores que escrevem código paralelo com números de ponto flutuante precisam verificar se os resultados são confiáveis, mesmo que não deem exatamente a mesma resposta que o código sequencial. O campo que lida com essas questões é a análise numérica, abordada em diversos
livros-texto voltados para esse assunto. Esses problemas são bons motivos para a popularidade das bibliotecas numéricas, como LAPACK e SCALAPAK, que foram validadas em suas formas sequencial e paralela. Armadilha: a instrução MIPS add immediate unsigned (addiu) estende o sinal de seu campo imediato de 16 bits. Apesar de seu nome, add immediate unsigned (addiu) é usada para somar constantes a inteiros com sinal quando não nos importamos com o overflow. O MIPS não possui uma instrução de subtração imediata e os números negativos precisam de extensão de sinal, de modo que os arquitetos do MIPS decidiram estender o sinal do campo imediato. Falácia: somente os matemáticos teóricos se importam com a precisão do ponto flutuante. As manchetes dos jornais de novembro de 1994 provam que essa afirmação é uma falácia (Figura 3.25). A seguir, está a história por trás das manchetes.
FIGURA 3.25 Uma amostra dos artigos de jornais e revistas de novembro de 1994, incluindo New York Times, San Jose Mercury News, San Francisco Chronicle e Infoworld. O bug da divisão de ponto flutuante do Pentium chegou até mesmo à “Lista dos 10 mais” do David Letterman Late Show na televisão. A Intel acabou tendo um custo de US$300 milhões para substituir os chips com defeito.
O Pentium usava um algoritmo de divisão de ponto flutuante padrão, que gera bits de quociente múltiplos por etapa, usando os bits mais significativos do divisor e do dividendo para descobrir os 2 bits seguintes do quociente. A escolha vem de uma tabela de pesquisa contendo –2, –1, 0, +1 ou +2. A escolha é multiplicada pelo divisor e subtraída do resto a fim de gerar um novo resto. Assim como a divisão sem restauração, se uma escolha anterior obtiver um resto muito grande, o resto parcial é ajustado em um momento subsequente. Evidentemente, havia cinco elementos da tabela do 80486 que os engenheiros da Intel pensaram que nunca poderiam ser acessados, e eles otimizaram a lógica para retornar 0 no lugar de 2 nessas situações no Pentium. A Intel estava errada: embora os 11 primeiros bits sempre fossem corretos, erros apareceriam ocasionalmente nos bits de 12 a 52 ou do 4o ao 15o dígito decimal.
Um professor de matemática no Lynchburg College, na Virgínia, Thomas Nicely, descobriu o bug em setembro de 1994. Depois de ligar para o suporte técnico da Intel e não receber uma posição oficial, ele posta sua descoberta na Internet. Essa postagem gerou uma história em uma revista do setor, que por sua vez, fez com que a Intel emitisse um comunicado oficial. Ela chamou o bug de um “glitch” que afetaria apenas os matemáticos teóricos, com um usuário normal de planilha vendo um erro somente a cada 27.000 anos. A IBM Research logo contra-argumentou que o usuário comum de planilha veria um erro a cada 24 dias. No fim, a Intel jogou a toalha, lançando o seguinte comunicado no dia 21 de dezembro: “Nós, da Intel, queremos sinceramente pedir desculpas por nosso tratamento da falha recentemente publicada do processador Pentium. O símbolo Intel Inside significa que seu computador possui um microprocessador que não fica atrás de nenhum outro em qualidade e desempenho. Milhares de funcionários da Intel trabalham muito para garantir que isso aconteça. Mas nenhum microprocessador é totalmente perfeito. O que a Intel continua a acreditar é que, tecnicamente, um problema extremamente pequeno assumiu vida própria. Embora a Intel mantenha a qualidade da versão atual do processador Pentium, reconhecemos que muitos usuários estejam preocupados. Queremos despreocupá-los. A Intel trocará a versão atual do processador Pentium por uma versão atualizada, em que essa falha de divisão de ponto flutuante está corrigida, para qualquer proprietário que o solicite, sem qualquer custo, durante toda a vida de seu computador”. Os analistas estimam que essa troca custou à Intel cerca de US$500 milhões, e os engenheiros da Intel não receberam um bônus de Natal naquele ano. Essa história nos faz refletir sobre alguns pontos. Quão mais econômico seria ter consertado o bug em julho de 1994? Qual foi o custo para reparar o dano causado à reputação da Intel? E qual é a responsabilidade corporativa na divulgação de bugs em um produto tão utilizado e de que tantos dependem como um microprocessador?
3.10. Comentários finais
Com o passar das décadas, a aritmética computacional tornou-se padronizada, aumentando bastante a portabilidade dos programas. A aritmética de inteiros binários com complemento de dois é encontrada em cada computador vendido hoje e, se incluir suporte para ponto flutuante, oferece aritmética de ponto flutuante binário do padrão IEEE 754. A aritmética computacional distingue-se da aritmética de lápis e papel pelas restrições da precisão limitada. Esse limite pode resultar em operações inválidas, por meio do cálculo de números maiores ou menores do que os limites predefinidos. Essas anomalias, chamadas “overflow” ou “underflow”, podem resultar em exceções ou interrupções, eventos de emergência, semelhantes a chamadas de sub-rotina não planejadas. Os Capítulos 4 e 5 discutem as exceções com mais detalhes. A aritmética de ponto flutuante tem o desafio adicional de ser uma aproximação de números reais e é preciso tomar cuidado para garantir que o número selecionado pelo computador seja a representação mais próxima do número real. Os desafios da imprecisão e da representação limitada fazem parte da inspiração para o campo da análise numérica. A recente mudança para o paralelismo acenderá novamente os holofotes sobre a análise numérica, à medida que soluções que eram consideradas seguras nos computadores sequenciais precisam ser reconsideradas quando se tenta encontrar o algoritmo mais rápido para computadores paralelos, que ainda alcance um resultado correto.
O paralelismo em nível de dados, especificamente o paralelismo subword, oferece um caminho simples para o desempenho mais alto de programas que usam operações aritméticas intensamente para dados inteiros e de ponto flutuante. Mostramos que é possível agilizar a multiplicação matricial tornando-a quase quatro vezes mais rápida, usando instruções que poderiam executar quatro operações de ponto flutuante de uma só vez. Com a explicação sobre aritmética computacional deste capítulo vem uma descrição detalhada do conjunto de instruções do MIPS. Uma questão que gera confusão são as instruções explicadas neste capítulo versus as instruções executadas pelos chips MIPS versus as instruções aceitas pelos montadores MIPS. As duas figuras seguintes tentam esclarecer isso. A Figura 3.26 lista as instruções MIPS abordadas neste capítulo e no Capítulo 2. Chamamos o conjunto de instruções da esquerda da figura de núcleo MIPS. As instruções à direita são chamadas núcleo aritmético MIPS. No lado esquerdo da Figura 3.27 estão as instruções que o processador MIPS executa que não se encontram na Figura 3.26. Chamamos o conjunto completo de instrução de hardware de MIPS-32. À direita da Figura 3.27 estão as instruções aceitas pelo montador, que não fazem parte do MIPS-32. Chamamos esse conjunto de instruções de PseudoMIPS.
FIGURA 3.26 O conjunto de instruções MIPS. Este livro se concentra nas instruções da coluna à esquerda. Essa informação também se encontra nas colunas 1 e 2 do Guia de Referência do MIPS no final deste livro.
FIGURA 3.27 Conjuntos de instruções MIPS-32 restantes e “pseudoMIPS”. f significa instruções de ponto flutuante com precisão simples (s) ou dupla (d) e s significa versões com sinal e sem sinal (u). MIPS-32 também possui instruções de PF para multiply e add/sub (madd.f/msub.f), ceiling (ceil.f), truncate (trunc.f), round (round.f) e reciprocal (recip.f). O sublinhado representa a letra a ser incluída para representar esse tipo de dados.
A Figura 3.28 indica a popularidade das instruções MIPS para os benchmarks de inteiro e de ponto flutuante SPEC CPU2006. Todas as instruções listadas foram responsáveis por, pelo menos, 0,2% das instruções executadas.
FIGURA 3.28 Frequência das instruções MIPS para o benchmark de inteiros e ponto flutuante SPEC CPU2006. Todas as instruções responsáveis por, pelo menos, 0,2% das instruções estão incluídas na tabela. As pseudoinstruções são convertidas em MIPS-32 antes da execução e, portanto, não aparecem aqui.
Observe que, embora os programadores e escritores de compilador possam utilizar MIPS-32 para ter um menu de opções mais rico, as instruções do núcleo MIPS dominam a execução SCPEC CPU2006 de inteiros, e o núcleo de inteiros mais aritmético domina o ponto flutuante SPEC CPU2006, como mostra a tabela a seguir. Subconjunto de instruções Inteiros Ponto flutuante Núcleo do MIPS
98%
31%
Núcleo aritmético do MIPS
2%
66%
MIPS-32 restante
0%
3%
Para o restante do livro, vamos nos concentrar nas instruções do núcleo MIPS — o conjunto de instruções de inteiros, excluindo multiplicação e divisão — para facilitar a explicação do projeto do computador. Como podemos ver, o núcleo MIPS inclui as instruções MIPS mais comuns, e tenha certeza de que compreender um computador que execute o núcleo MIPS lhe dará base suficiente para entender computadores com projetos ainda mais ambiciosos. Não
importa qual seja o conjunto de instruções ou seu tamanho — MIPS, ARM, x86 —, nunca se esqueça de que os padrões de bits não possuem significado inerente. O mesmo padrão de bits pode representar um inteiro com sinal, um inteiro sem sinal, um número de ponto flutuante, uma string, uma instrução etc. Nos computadores com programa armazenado, é a operação sobre o padrão de bits que determina seu significado.
3.11. Exercícios Nunca ceda, nunca ceda, nunca, nunca, nunca – em nada, seja grande ou pequeno, importante ou insignificante – nunca ceda. Winston Churchill, discurso na Harrow School, 1941 3.1. [5] O que é 5ED4–07A4 quando esses valores representam números hexadecimais de 16 bits sem sinal? O resultado deverá ser escrito em hexadecimal. Mostre o seu trabalho. 3.2. [5] O que é 5ED4–07A4 quando esses valores representam números hexadecimais de 16 bits com sinal, armazenados no formato sinalmagnitude? O resultado deverá ser escrito em hexadecimal. Mostre o seu trabalho. 3.3. [10] Converta 5ED4 em um número binário. O que torna a base 16 (hexadecimal) um sistema de numeração atraente para representar valores nos computadores? 3.4. [5] O que é 4365–3412 quando esses valores representam números octais de 12 bits sem sinal? O resultado deverá ser escrito em octal. Mostre seu trabalho. 3.5. [5] O que é 4365–3412 quando esses valores representam números octais de 12 bits com sinal, armazenados no formato sinal-magnitude? O resultado deverá ser escrito em octal. Mostre seu trabalho. 3.6. [5] Suponha que 185 e 122 sejam decimais inteiros de 8 bit sem sinal. Calcule 185–122. Existe overflow, underflow ou nenhum destes? 3.7. [5] Suponha que 185 e 122 sejam inteiros decimais de 8 bits com sinal, armazenados no formato sinal-magnitude. Calcule 185 + 122. Existe overflow, underflow ou nenhum destes?
3.8. [5] Suponha que 185 e 122 sejam inteiros decimais de 8 bits com sinal, armazenados no formato sinal-magnitude. Calcule 185–122. Existe overflow, underflow ou nenhum destes? 3.9. [10] Suponha que 151 e 214 sejam inteiros decimais de 8 bits com sinal, armazenados no formato de complemento de dois. Calcule 151 + 214 usando a aritmética com saturação. O resultado deverá ser escrito em decimal. Mostre seu trabalho. 3.10. [10] Suponha que 151 e 214 sejam inteiros decimais de 8 bits com sinal, armazenados no formato de complemento de dois. Calcule 151–214 usando a aritmética com saturação. O resultado deverá ser escrito em decimal. Mostre seu trabalho. 3.11. [10] Suponha que 151 e 214 sejam inteiros de 8 bits sem sinal. Calcule 151 + 214 a aritmética com saturação. O resultado deverá ser escrito em decimal. Mostre seu trabalho. 3.12. [20] Usando uma tabela semelhante à que mostramos na Figura 3.6, calcule o produto dos inteiros octais de 6 bits sem sinal 62 e 12 usando o hardware descrito na Figura 3.3. Você deverá mostrar o conteúdo dos registradores em cada etapa. 3.13. [20] Usando uma tabela semelhante à que mostramos na Figura 3.6, calcule o produto dos inteiros hexadecimais de 8 bits sem sinal 62 e 12 usando o hardware descrito na Figura 3.5. Você deverá mostrar o conteúdo de cada registrador em cada etapa. 3.14. [10] Calcule o tempo necessário para realizar uma multiplicação usando a técnica dada nas Figuras 3.3 e 3.4 se um inteiro tiver 8 bits de largura e cada etapa da operação exigir 4 unidades de tempo. Suponha que, na etapa 1a, uma adição sempre é realizada — ou o multiplicando ou o 0 será somado. Suponha também que os registradores já foram inicializados (você está simplesmente contando quanto tempo é necessário para se realizar o próprio loop de multiplicação). Se isso estiver sendo feito no hardware, os deslocamentos do multiplicando e do multiplicador podem ser feitos simultaneamente. Se isso estiver sendo feito no software, eles terão de ser feitos um após o outro. Solucione para cada caso. 3.15. [10] Calcule o tempo necessário para realizar uma multiplicação usando a técnica descrita no texto (31 somadores empilhados verticalmente) se um inteiro tiver 8 bits de largura e um somador exigir 4 unidades de tempo. 3.16. [20] Calcule o tempo necessário para realizar uma multiplicação
usando a técnica dada na Figura 3.7, se um inteiro tiver 8 bits de largura e um somador exigir 4 unidades de tempo. 3.17. [20] Conforme discutimos no texto, uma possível melhoria no desempenho é realizar um deslocamento e soma em vez de uma multiplicação real. Como 9 × 6, por exemplo, pode ser escrito como (2 × 2 × 2 + 1) × 6, podemos calcular 9 × 6 deslocando 6 para a esquerda três vezes e depois somando 6 a esse resultado. Mostre a melhor maneira de calcular 0 × 33 × 0 × 55 usando deslocamentos e adições/subtrações. Suponha que ambas as entradas sejam inteiros de 8 bits sem sinal. 3.18. [20] Usando uma tabelas semelhante à que mostramos na Figura 3.10, calcule 74 dividido por 21 usando o hardware descrito na Figura 3.8. Você deverá mostrar o conteúdo de cada registrador em cada etapa. Suponha que as duas entradas sejam inteiros de 6 bits sem sinal. 3.19. [30] Usando uma tabela semelhante à que mostramos na Figura 3.10, calcule 74 dividido por 21 usando o hardware descrito na Figura 3.11. Você deverá mostrar o conteúdo de cada registrador em cada etapa. Suponha que as duas entradas sejam inteiros de 6 bits sem sinal. Este algoritmo requer uma técnica ligeiramente diferente daquela mostrada na Figura 3.9. Você deverá pensar bem nisso, realizando de um a dois experimentos ou então vá à Web descobrir como fazer isso funcionar corretamente. (Dica: uma solução possível envolve o fato de que a Figura 3.11 possa deslocar o registrador de resto em qualquer direção.) 3.20. [5] Que número decimal o padrão de bits 0x0C000000 representa se ele for um inteiro em complemento de dois? E um inteiro sem sinal? 3.21. [10] Se o padrão de bits 0x0C000000 for colocado no Registrador de Instrução, que instrução MIPS será executada? 3.22. [10] Que número decimal o padrão de bits 0x0C000000 representa se ele for um número de ponto flutuante? Use o padrão IEEE 754. 3.23. [10] Escreva a representação binária do número decimal 63,25, considerando o formato de precisão simples IEEE 754. 3.24. [10] Escreva a representação binária do número decimal 63,25, considerando o formato de precisão dupla IEEE 754. 3.25. [10] Escreva a representação binária do número decimal 63,25 considerando que ele foi armazenado usando o formato IBM de precisão simples (base 16, em vez da base 2, com 7 bits de expoente). 3.26. [20] Escreva o padrão de bits binário para representar −1,5625 × 10–1 considerando um formato semelhante ao empregado pelo
DEC PDP-8 (12 bits da esquerda são o expoente armazenado como um número de complemento de dois, e os 24 bits da direita são a fração armazenada como um número de complemento de dois). Nenhum 1 oculto é utilizado. Compare o intervalo e a precisão desse padrão de 36 bits com os padrões IEEE 754 de precisão simples e dupla. 3.27. [20] IEEE 754-2008 tem um formato de meia precisão, que tem apenas 16 bits de largura. O bit mais à esquerda ainda é o bit de sinal, o expoente tem 5 bits de largura e possui um bias de 15, e a mantissa tem 10 bits de extensão. Assume-se que existe um 1 oculto. Escreva o padrão de bits para representar –1,5625 × 10−1 considerando uma versão modificada desse formato que utiliza um formato com excesso de 16 para armazenar o expoente. Comente sobre o intervalo e a precisão desse formato de ponto flutuante de 16 bits com o padrão IEEE 754 de precisão simples. 3.28. [20] Os Hewlett-Packard 2114, 2115 e 2116 usavam um formato com os 16 bits mais à esquerda sendo a fração armazenada no formato de complemento de dois, seguida por outro campo de 16 bits que tinha nos 8 bits mais à esquerda uma extensão da fração (fazendo com que a fração tenha 24 bits de extensão) e os 8 bits mais à direita representando o expoente. Porém, por um capricho interessante, o expoente era armazenado em formato de magnitude de sinal com o bit de sinal no canto direito! Escreva o padrão de bits para representar −1,5625 × 10−1 considerando esse formato. Nenhum 1 oculto é utilizado. Compare o intervalo e a precisão desse padrão de 32 bits com o padrão IEEE 754 de precisão simples. 3.29. [20] Calcule a soma de 2,6125 × 101 e 4,150390625 × 10−1 à mão, supondo que ambas as entradas sejam armazenadas no formato de meia precisão com 16 bits, descrito no Exercício 3.27. Considere um bit de guarda, um bit de arredondamento e um sticky bit, e arredonde para o par mais próximo. Mostre todas as etapas. 3.30. [30] Calcule o produto de −8,0546875 × 100 e −1,79931640625 × 10−1 manualmente, considerando que ambas as entradas sejam armazenadas no formato de meia-precisão com 16 bits descrito no Exercício 3.27. Considere um bit de guarda, um bit de arredondamento e um sticky bit, e arredonde para o par mais próximo. Mostre todas as etapas; porém, como acontece no exemplo do texto, você pode realizar a multiplicação em formato legível para humanos, em vez de usar as técnicas descritas nos Exercícios de 3.12 a 3.14. Indique se existe overflow ou underflow. Escreva sua resposta no formato de 16 bits em ponto flutuante descrito no Exercício
3.27 e também como um número decimal. Qual é a precisão do seu resultado? Compare-o com o número que você obtém se realizar a multiplicação em uma calculadora. 3.31. [30] Calcule 8,625 × 101 dividido por −4,875 × 100 manualmente. Mostre todas as etapas necessárias para se chegar à sua resposta. Suponha que exista um bit de guarda, de arredondamento e um sticky bit, e use-os, se for necessário. Escreva a resposta final em formato de ponto flutuante com 16 bits descrito no Exercício 3.27 e em decimal, comparando o resultado decimal com o que você obtém usando uma calculadora. 3.32. [20] Calcule (3,984375 × 10−1 + 3,4375 × 10−1) + 1,771 × 103 manualmente, considerando que cada um dos valores é armazenado no formato de meia-precisão com 16 bits, descrito no Exercício 3.27 (também descrito no texto). Considere um bit de guarda, um bit de arredondamento e um sticky bit, e arredonde para o par mais próximo. Mostre todas as etapas e escreva sua resposta em formato de ponto flutuante de 16 bits e em decimal. 3.33. [20] Calcule 3,984375 × 10−1 + (3,4375 × 10−1 + 1,771 × 103) manualmente, considerando que cada um dos valores é armazenado no formato de meia-precisão com 16 bits, descrito no Exercício 3.27 (também descrito no texto). Considere um bit de guarda, um bit de arredondamento e um sticky bit, e arredonde para o par mais próximo. Mostre todas as etapas, e escreva sua resposta em formato de ponto flutuante de 16 bits e em decimal. 3.34. [10] Com base nas suas respostas dos Exercícios 3.32 e 3.33, (3,984375 × 10−1 + 3,4375 × 10−1) + 1,771 × 103 = 3,984375 × 10−1 + (3,4375 × 10−1 + 1,771 × 103)? 3.35. [30] Calcule (3,41796875 × 10−3 × 6,34765625 × 10−3) × 1,05625 × 102 manualmente, considerando que cada um dos valores é armazenado no formato de meia precisão com 16 bits, descrito no Exercício 3.27 (também descrito no texto). Considere um bit de guarda, um bit de arredondamento e um sticky bit, e arredonde para o par mais próximo. Mostre todas as etapas, e escreva sua resposta em formato de ponto flutuante de 16 bits e em decimal. 3.36. [30] Calcule 3,41796875 × 10−3 × (6,34765625 × 10−3 × 1,05625 × 102) manualmente, considerando que cada um dos valores é armazenado no formato de meia precisão com 16 bits, descrito no Exercício 3.27 (também descrito no texto). Considere um bit de guarda, um bit de arredondamento e um sticky bit, arredondando para o par mais próximo. Mostre todas as etapas, e escreva sua resposta em formato de ponto flutuante de 16 bits e em decimal.
3.37. [10] Com base nas suas respostas dos Exercícios 3.35 e 3.36, (3,41796875 × 10−3 × 6,34765625 × 10−3) × 1,05625 × 102 = 3,41796875 × 10−3 × (6,34765625 × 10−3 × 1,05625 × 102)? 3.38. [30] Calcule 1,666015625 × 100 × (1,9760 × 104 + −1,9744 × 104) manualmente, considerando que cada um dos valores é armazenado no formato de meia-precisão com 16 bits, descrito no Exercício 3.27 (descrito no texto). Considere um bit de guarda, um bit de arredondamento e um sticky bit, arredondando para o par mais próximo. Mostre todas as etapas, e escreva sua resposta em formato de ponto flutuante de 16 bits e em decimal. 3.39. [30] Calcule (1,666015625 × 100 × 1,9760 × 104) + (1,666015625 × 100 × −1,9744 × 104) manualmente, considerando que cada um dos valores é armazenado no formato de meia-precisão com 16 bits, descrito no Exercício 3.27 (também descrito no texto). Considere um bit de guarda, um bit de arredondamento e um sticky bit, arredondando para o par mais próximo. Mostre todas as etapas, e escreva sua resposta em formato de ponto flutuante de 16 bits e em decimal. 3.40. [10] Com base nas suas respostas dos Exercícios 3.38 e 3.39, confirme se (1,666015625 × 100 × 1,9760 × 104) + (1,666015625 × 100 × −1,9744 × 104) = 1,666015625 × 100 × (1,9760 × 104 + −1,9744 × 104)? 3.41. [10] Usando o formato de ponto flutuante IEEE 754, escreva o padrão de bits que representaria –1/4. Você consegue representar –1/4 com exatidão? 3.42. [10] O que você obtém se somar −1/4 a si mesmo 4 vezes? Quanto é −1/4 × 4? Eles são iguais? O que deveriam ser? 3.43. [10] Escreva o padrão de bits na fração de valor 1/3 considerando um formato de ponto flutuante que usa números binários na fração. Suponha que existam 24 bits e você não precisa normalizar. Essa representação é exata? 3.44. [10] Escreva o padrão de bits na fração de valor 1/3 considerando um formato de ponto flutuante que usa números Binary Coded Decimal (base 10) na fração, em vez da base 2. Suponha que existam 24 bits e você não precisa normalizar. Essa representação é exata? 3.45. [10] Escreva o padrão de bits supondo que estamos usando números de base 15 na fração de valor 1/3, em vez da base 2. (Números de base 16 utilizam os símbolos 0-9 e A-F. Números de base 15 usariam 0-9 e A-E.) Suponha que existam 24 bits e você não precisa normalizar. Essa representação é exata?
3.46. [20] Escreva o padrão de bits supondo que estamos usando números de base 30 na fração de valor 1/3, em vez da base 2. (Números de base 16 utilizam os símbolos 0-9 e A-F. Números de base 30 usariam 0-9 e A-T.) Suponha que existam 20 bits e você não precisa normalizar. Essa representação é exata? 3.47. [45] O código C a seguir implementar um filtro FIR de quatro tomadas no array de entrada sig_in. Suponha que todos os arrays sejam valores de ponto fixo com 16 bits.
Suponha que você tenha que escrever uma implementação otimizada deste código com linguagem assembly em um processador que possui instruções SIMD e registradores de 128 bits. Sem conhecer os detalhes do conjunto de instruções, descreva resumidamente como você implementaria esse código, maximizando o uso de operações de subword e minimizando a quantidade de dados que é transferida entre registradores e memória. Escreva todas as suas suposições sobre as instruções que você utiliza.
Respostas das Seções “Verifique você mesmo” §3.2: página 158: 3. §3.5: página 195: 3.
O Processador Em um assunto importante, nenhum detalhe é pequeno. Provérbio francês
4.1 Introdução 4.2 Convenções lógicas de projeto 4.3 Construindo um caminho de dados 4.4 Um esquema de implementação simples 4.5 Visão geral de pipelining 4.6 Caminho de dados e controle usando pipeline 4.7 Hazards de dados: forwarding versus stalls 4.8 Hazards de controle 4.9 Exceções 4.10 Paralelismo e paralelismo avançado em nível de instrução 4.11 Vida real: pipelines do ARM Cortex-A8 e Intel Core i7 4.12 Mais rápido: Paralelismo em nível de instrução e multiplicação matricial 4.13 Falácias e armadilhas 4.14 Comentários finais 4.15 Exercícios
Os cinco componentes clássicos de um computador
4.1. Introdução O Capítulo 1 explica que o desempenho de um computador é determinado por três fatores principais: contagem de instruções, tempo de ciclo de clock e ciclos de clock por instrução (CPI). O Capítulo 2 explica que o compilador e a arquitetura do conjunto de instruções determinam a contagem de instruções necessária para um determinado programa. Entretanto, tanto o tempo de ciclo de
clock quanto o número de ciclos de clock por instrução são determinados pela implementação do processador. Neste capítulo, construímos o caminho de dados e a unidade de controle para duas implementações diferentes do conjunto de instruções MIPS. Este capítulo contém uma explicação dos princípios e das técnicas usadas na implementação de um processador, começando com uma sinopse altamente abstrata e simplificada nesta seção. Ela é seguida de uma seção que desenvolve um caminho de dados e constrói uma versão simples de um processador, suficiente para implementar conjuntos de instruções como o MIPS. O corpo do capítulo descreve uma implementação MIPS em pipelining mais realista, seguida de uma seção que desenvolve conceitos necessários para implementar conjuntos de instruções mais complexos, como o x86.
Para o leitor interessado em entender a interpretação de alto nível de instruções e seu impacto sobre o desempenho do programa, esta seção inicial e a Seção 4.5 apresentam os conceitos básicos do pipelining. Tendências recentes são abordadas na Seção 4.10, e a Seção 4.11 descreve as arquiteturas recentes Intel Core i7 e ARM Cortex-A8. A Seção 4.12 mostra como usar o paralelismo
em nível de instrução para mais do que dobrar o desempenho da multiplicação matricial da Seção 3.8. Estas seções oferecem uma base suficiente para entender os conceitos de pipeline em um alto nível. Para os leitores que desejam um entendimento do processador e seu desempenho com mais profundidade, as Seções 4.3, 4.4 e 4.6 serão úteis. Aqueles interessados em aprender como montar um processador também devem ler as Seções 4.2, 4.7, 4.8 e 4.9.
Uma implementação MIPS básica Analisaremos uma implementação que inclui um subconjunto do conjunto de instruções MIPS básico. ▪ As instruções de referência à memória load word (lw) e store word (sw). ▪ As instruções lógicas e aritméticas add, sub, AND, OR e slt. ▪ As instruções brench equal (beq) e jump (j), que acrescentamos depois. Esse subconjunto não inclui todas as instruções de inteiro (por exemplo, shift, multiply e divide estão ausentes), nem inclui qualquer instrução de ponto flutuante. Entretanto, os princípios básicos usados na criação de um caminho de dados e no projeto do controle são ilustrados. A implementação das outras instruções é semelhante. Examinando a implementação, teremos a oportunidade de ver como o conjunto de instruções determina muitos aspectos da implementação e como a escolha de várias estratégias de implementação afeta a velocidade de clock e o CPI para o computador. Muitos dos princípios básicos de projeto apresentados no Capítulo 1 podem ser ilustrados, considerando-se a implementação, como o princípio A simplicidade favorece a regularidade. Além disso, a maioria dos conceitos usados para implementar o subconjunto MIPS, neste capítulo e no próximo, envolvem as mesmas ideias básicas usadas para construir um amplo espectro de computadores, desde servidores de alto desempenho a microprocessadores de finalidade geral e processadores embutidos. Uma sinopse da implementação No Capítulo 2 vimos instruções MIPS básicas, incluindo as instruções lógicas e aritméticas, as de referência à memória e as de desvio. Muito do que precisa ser feito para implementar essas instruções é igual, independentemente da classe exata da instrução. Para cada instrução, as duas primeiras etapas são idênticas: 1. Enviar o contador de programa (PC) à memória que contém o código e
buscar a instrução dessa memória. 2. Ler um ou mais registradores, usando campos da instrução para selecionar os registradores a serem lidos. Para a instrução load word, precisamos ler apenas um registrador, mas a maioria das outras instruções exige a leitura de dois registradores. Após essas duas etapas, as ações necessárias para completar a instrução dependem da classe da instrução. Felizmente, para cada uma das três classes de instrução (referência à memória, lógica e aritmética, e desvios), as ações são quase as mesmas, seja qual for a instrução exata. A simplicidade e a regularidade do conjunto de instruções simplifica a implementação tornando semelhantes as execuções de muitas das classes de instrução. Por exemplo, todas as classes de instrução, exceto jump, usam a unidade lógica e aritmética (ALU) após a leitura dos registradores. As instruções de referência à memória usam a ALU para o cálculo de endereço, as instruções lógicas e aritméticas para a execução da operação e desvios para comparação. Após usar a ALU, as ações necessárias para completar várias classes de instrução diferem. Uma instrução de referência à memória precisará acessá-la, a fim de escrever dados para um load ou ler dados para um store. Uma instrução lógica e aritmética precisa escrever os dados da ALU de volta a um registrador. Finalmente, para uma instrução de desvio, podemos ter de mudar o próximo endereço de instrução com base na comparação; caso contrário, o PC deve ser incrementado em 4, a fim de chegar ao endereço da próxima instrução. A Figura 4.1 mostra a visão em alto nível de uma implementação MIPS, focando as várias unidades funcionais e sua interconexão. Embora essa figura mostre a maioria do fluxo de dados pelo processador, ela omite dois importantes aspectos da execução da instrução.
FIGURA 4.1 Uma visão abstrata da implementação do subconjunto MIPS mostrando as principais unidades funcionais e as principais conexões entre elas. Todas as instruções começam usando o contador de programa para fornecer o endereço de instrução para a memória de instruções. Depois que a instrução é trazida, os registradores usados como operandos pela instrução são especificados por campos dessa instrução. Uma vez que os operandos tenham sido trazidos, eles podem ser operados de modo a calcular um endereço de memória (para um load ou store), calcular um resultado aritmético (para uma instrução lógica ou aritmética) ou a comparação (para um desvio). Se a instrução for uma instrução lógica ou aritmética, o resultado da ALU precisa ser escrito em um registrador. Se a operação for um load ou store, o resultado da ALU é usado como um endereço com a finalidade de armazenar o valor de um registrador ou ler um valor da memória para um registrador. O resultado da ALU ou memória é escrito de volta no banco de registradores. Os desvios exigem o uso da saída da ALU para determinar o endereço da próxima instrução, que vem da ALU (em que o offset do PC e do desvio são somados) ou de um somador que incrementa o PC atual em 4. As linhas grossas interconectando as unidades funcionais representam barramentos, que consistem em múltiplos sinais. As setas são usadas para guiar o leitor sobre como as informações fluem. Como as linhas de sinal podem se cruzar, mostramos explicitamente quando as linhas que se cruzam estão conectadas pela presença de um ponto no local do cruzamento.
Primeiro, em vários lugares, a Figura 4.1 mostra os dados indo para uma determinada unidade, vindo de duas origens diferentes. Por exemplo, o valor escrito no PC pode vir de dois somadores, os dados escritos no banco de registradores podem vir da ALU ou da memória de dados, e a segunda entrada da ALU pode vir de um registrador ou do campo imediato da instrução. Na prática, essas linhas de dados não podem simplesmente ser interligadas; precisamos adicionar um elemento que escolha dentre as diversas origens e conduza uma dessas origens a seu destino. Essa seleção normalmente é feita com um dispositivo chamado multiplexador, embora uma melhor denominação desse dispositivo seria seletor de dados. O Apêndice B descreve o multiplexador, que seleciona entre várias entradas com base na configuração de suas linhas de controle. As linhas de controle são definidas principalmente com base na informação tomada da instrução sendo executada. A segunda omissão na Figura 4.1 é que várias das unidades precisam ser controladas de acordo com o tipo da instrução. Por exemplo, a memória de dados precisa ler em um load e escrever em um store. O banco de registradores precisa ser escrito apenas em uma instrução load ou em uma instrução lógica ou aritmética. E, é claro, a ALU precisa realizar uma de várias operações. (O Apêndice B descreve o projeto detalhado da ALU.) Assim como os multiplexadores, essas operações são direcionadas por linhas de controle que são definidas com base nos vários campos das instruções. A Figura 4.2 mostra o caminho de dados da Figura 4.1 com os três multiplexadores necessários acrescentados, bem como as linhas de controle para as principais unidades funcionais. Uma unidade de controle, que tem a instrução como uma entrada, é usada para determinar como definir as linhas de controle para as unidades funcionais e dois dos multiplexadores. O terceiro multiplexador – que determina se PC + 4 ou o endereço de destino do desvio é escrito no PC – é definido com base na saída zero da ALU, usada para realizar a comparação da instrução beq. A regularidade e a simplicidade do conjunto de instruções MIPS significam que um simples processo de decodificação pode ser usado no sentido de determinar como definir as linhas de controle.
FIGURA 4.2 A implementação básica do subconjunto MIPS incluindo os multiplexadores necessários e as linhas de controle. O multiplexador superior (“Mux”) controla que valor substitui o PC (PC + 4 ou o endereço de destino do desvio); o multiplexador é controlado pela porta que realiza um AND da saída Zero da ALU com um sinal de controle que indica que a instrução é de desvio. O multiplexador do meio, cuja saída retorna para o banco de registradores, é usado para conduzir a saída da ALU (no caso de uma instrução lógica ou aritmética) ou a saída da memória de dados (no caso de um load) a ser escrita no banco de registradores. Finalmente, o multiplexador da parte inferior é usado de modo a determinar se uma segunda entrada da ALU vem dos registradores (para uma instrução lógica-aritmética OU um desvio) ou do campo offset da instrução (para um load ou store). As linhas de controle acrescentadas são simples e determinam a operação realizada pela ALU, se a memória de dados deve ler ou escrever e se os registradores devem realizar uma operação de escrita. As linhas de controle são mostradas em tons de cinza para que sejam vistas com mais facilidade.
No restante do capítulo, refinamos essa visão para preencher os detalhes, o que exige que acrescentemos mais unidades funcionais, aumentemos o número das conexões entre unidades e, é claro, adicionemos uma unidade de controle, a fim de controlar que ações são realizadas para diferentes classes de instrução. As Seções 4.3 e 4.4 descrevem uma implementação simples que usa um único ciclo de clock longo para cada instrução e segue a forma geral das Figuras 4.1 e 4.2. Nesse primeiro projeto, cada instrução começa a execução em uma transição do clock e completa a execução na próxima transição do clock. Embora seja mais fácil de entender, esse método não é prático, já que o ciclo de clock precisa ser bastante esticado para acomodar a instrução mais longa. Após projetar o controle desse computador simples, veremos uma implementação em pipeline com todas as suas complexidades, incluindo as exceções.
Verifique você mesmo Quantos dos cinco componentes clássicos de um computador — mostrados no início deste capítulo — as Figuras 4.1 e 4.2 contêm?
4.2. Convenções lógicas de projeto Para tratar do projeto de um computador, precisamos decidir como a implementação lógica do computador irá operar e como esse computador está sincronizado. Esta seção examina algumas ideias básicas na lógica digital que usaremos em todo o capítulo. Se você tiver pouco ou nenhum conhecimento em lógica digital, provavelmente será útil ler o Apêndice B antes de continuar.
elemento combinacional Um elemento operacional, como uma porta AND ou uma ALU. Os elementos do caminho de dados na implementação MIPS consistem em dois tipos diferentes de elementos lógicos: aqueles que operam nos valores dos dados e os que contêm estado. Os elementos que operam nos valores dos dados são todos combinacionais, significando que suas saídas dependem apenas das entradas atuais. Dada a mesma entrada, um elemento combinacional sempre produz a mesma saída. A ALU mostrada na Figura 4.1 e discutida no Apêndice B é um exemplo de elemento combinacional. Dado um conjunto de entradas, ele
sempre produz a mesma saída porque não possui qualquer armazenamento interno. Outros elementos no projeto não são combinacionais, mas contêm estado. Um elemento contém estado se tiver algum armazenamento interno. Chamamos esses elementos de elementos de estado, pois, se desligássemos o computador da tomada, poderíamos reiniciá-lo carregando os elementos de estado com os valores que continham antes de interrompermos a energia. Além disso, se salvássemos e armazenássemos novamente os elementos de estado, seria como se o computador nunca tivesse sido desligado. Na Figura 4.1, as memórias de instruções e de dados, bem como os registradores, são exemplos de elementos de estado.
elemento de estado Um elemento da memória, como um registrador ou uma memória. Um elemento de estado possui pelo menos duas entradas e uma saída. As entradas necessárias são os valores dos dados a serem escritos no elemento e o clock, que determina quando o valor dos dados deve ser escrito. A saída de um elemento de estado fornece o valor escrito em um ciclo de clock anterior. Por exemplo, um dos elementos de estado mais simples logicamente é um flip-flop tipo D (Apêndice B), que possui exatamente essas duas entradas (um valor e um clock) e uma saída. Além dos flip-flops, nossa implementação MIPS também usa dois outros tipos de elementos de estado: memórias e registradores, ambos aparecendo na Figura 4.1. O clock é usado para determinar quando se deve escrever no elemento de estado; um elemento de estado pode ser lido a qualquer momento. Os componentes lógicos que contêm estado também são chamados de sequenciais porque suas saídas dependem de suas entradas e do conteúdo do estado interno. Por exemplo, a saída da unidade funcional representando os registradores depende dos números de registrador fornecidos e do que foi escrito nos registradores anteriormente. Tanto a operação dos elementos combinacionais e sequenciais, quanto sua construção são discutidas em mais detalhes no Apêndice B. Metodologia de clocking Uma metodologia de clocking define quando os sinais podem ser lidos e
quando podem ser escritos. Ela é importante para especificar a sincronização das leituras e escritas porque, se um sinal fosse escrito ao mesmo tempo em que fosse lido, o valor da leitura poderia corresponder ao valor antigo, ao valor recém-escrito ou mesmo alguma combinação dos dois! Obviamente, os projetos de computadores não podem tolerar essa imprevisibilidade. Uma metodologia de clocking tem o objetivo de garantir a previsibilidade.
metodologia de clocking O método usado para determinar quando os dados são válidos e estáveis em relação ao clock. Para simplificar, consideraremos uma metodologia de sincronização acionada por transição. Uma metodologia de sincronização acionada por transição significa que quaisquer valores armazenados em um elemento lógico sequencial são atualizados apenas em uma transição do clock, que é uma transição rápida de baixo para alto ou vice-versa (Figura 4.3). Como apenas os elementos de estado podem armazenar valores de dados, qualquer coleção de lógica combinatória precisa ter suas entradas vindo de um conjunto de elementos de estado e suas saídas escritas em um conjunto de elementos de estado. As entradas são valores escritos em um ciclo de clock anterior, enquanto as saídas são valores que podem ser usados em um ciclo de clock seguinte.
FIGURA 4.3 A lógica combinacional, os elementos de estado e o clock estão intimamente relacionados. Em um sistema digital síncrono, o clock determina quando os elementos com estado escreverão valores no armazenamento interno. Quaisquer entradas em um elemento de estado precisam atingir um valor estável (ou seja, ter alcançado um valor do qual não mudarão até após a transição do clock) antes
que a transição ativa do clock faça com que o estado seja atualizado. Todos os elementos de estado neste capítulo, incluindo a memória, são considerados acionados por transição positiva; ou seja, eles mudam na transição de subida do clock.
sincronização acionada por transição Um esquema de clocking em que todas as mudanças de estado ocorrem em uma transição do clock. A Figura 4.3 mostra os dois elementos de estado em volta de um bloco de lógica combinacional, que opera em um único ciclo de clock: todos os sinais precisam se propagar desde o elemento de estado 1, passando pela lógica combinacional e indo até o elemento 2 no tempo de um ciclo de clock. O tempo necessário para os sinais alcançarem o elemento 2 define a duração do ciclo de clock. Para simplificar, não mostraremos um sinal de controle de escrita quando um elemento de estado é escrito em cada transição ativa de clock. Por outro lado, se um elemento de estado não for atualizado em cada clock, um sinal de controle de escrita explícito é necessário. Tanto o sinal de clock quanto o sinal de controle de escrita são entradas, e o elemento de estado só é alterado quando o sinal de controle de escrita está ativo e ocorre uma transição do clock.
sinal de controle Um sinal usado para seleção de multiplexador ou para direcionar a operação de uma unidade funcional; contrasta com um sinal de dados, que contém informações operadas por uma unidade funcional. Usaremos o termo ativo para indicar um sinal que está logicamente alto, o termo ativar para especificar que um sinal deve ser conduzido a logicamente alto, e desativar ou inativo para representar o que é logicamente baixo. Usamos os termos ativar e desativar porque, ao implementarmos o hardware, às vezes 1 representa um sinal lógico alto, mas também pode representar um sinal lógico baixo.
ativo
O sinal está logicamente alto ou verdadeiro.
inativo O sinal está logicamente baixo ou falso. Uma metodologia acionada por transição permite ler o conteúdo de um registrador, enviar o valor por meio de alguma lógica combinatória e escrever nesse registrador no mesmo ciclo de clock. A Figura 4.4 mostra um exemplo genérico. Não importa se consideramos que todas as escritas ocorrem na transição de subida do clock ou na transição de descida, já que as entradas no bloco de lógica combinatória não podem mudar exceto na transição de clock escolhida. Com uma metodologia de sincronização acionada por transição, não há qualquer feedback dentro de um único ciclo de clock, e a lógica na Figura 4.4 funciona corretamente. No Apêndice B, discutimos brevemente as outras limitações (como os tempos de setup e hold), bem como outras metodologias de sincronização.
FIGURA 4.4 Uma metodologia acionada por transição permite que um elemento de estado seja lido e escrito no mesmo ciclo de clock sem criar uma disputa que poderia levar a valores de dados indeterminados. É claro que o ciclo de clock ainda precisa ser longo o suficiente para que os valores de entrada sejam estáveis quando houver transição ativa do clock. O feedback não pode ocorrer dentro de um ciclo de clock devido à atualização acionada por transição do elemento de estado. Se o feedback fosse possível, esse projeto não poderia funcionar corretamente. Nossos projetos neste capítulo e no próximo se baseiam na metodologia de sincronização acionada por transição e em estruturas como a mostrada nesta figura.
Para a arquitetura MIPS de 32 bits, quase todos esses elementos de estado e lógicos terão entradas e saídas contendo 32 bits de extensão, já que essa é a extensão da maioria dos dados manipulados pelo processador. Sempre que uma unidade tiver uma entrada ou saída diferente de 32 bits de extensão, deixaremos isso claro. As figuras indicarão barramentos (que são sinais mais largos do que 1 bit), com linhas mais grossas. Algumas vezes, desejaremos combinar vários barramentos para formar um barramento mais largo; por exemplo, podemos querer obter um barramento de 32 bits combinando dois de 16 bits. Nesses casos, rótulos nas linhas de barramento indicarão que estamos concatenando barramentos para formar um mais largo. Setas também são incluídas para ajudar a esclarecer a direção do fluxo dos dados entre elementos. Finalmente, o realce indica um sinal de controle em oposição a um sinal que conduz dados; essa distinção se tornará mais clara enquanto avançarmos neste capítulo.
Verifique você mesmo Verdadeiro ou falso: como o banco de registradores é lido e escrito no mesmo ciclo de clock, qualquer caminho de dados MIPS usando escritas acionadas por transição precisa ter mais de uma cópia do banco de registradores.
Detalhamento Há também uma versão de 64 bits da arquitetura MIPS e, naturalmente, a maioria dos caminhos em sua implementação teria 64 bits de largura.
4.3. Construindo um caminho de dados Uma maneira razoável de iniciar um projeto de caminho de dados é examinar os principais componentes necessários para executar cada classe de instrução MIPS. Vamos começar olhando quais elementos do caminho de dados cada instrução precisa, e depois desceremos por todos os níveis de abstração. Quando mostramos os elementos do caminho de dados, também mostramos seus sinais de controle. Usamos a abstração nesta explicação, começando de baixo para cima.
elemento do caminho de dados Uma unidade funcional usada para operar sobre os dados ou conter esses
dados dentro de um processador. Na implementação MIPS, os elementos do caminho de dados incluem as memórias de instruções e de dados, o banco de registradores, a unidade lógica e aritmética (ALU) e os somadores.
A Figura 4.5a mostra o primeiro elemento de que precisamos: uma unidade de memória para armazenar as instruções de um programa e fornecer instruções dado um endereço. A Figura 4.5b mostra um registrador, que podemos chamar de contador de programa (PC), que, como vimos no Capítulo 2, é um registrador que contém o endereço da instrução atual. Finalmente, precisaremos de um somador a fim de incrementar o PC para o endereço da próxima instrução. Esse somador, que é combinacional, pode ser construído a partir da ALU que descrevemos em detalhes no Apêndice B, simplesmente interligando as linhas de controle de modo que o controle sempre especifique uma operação de adição. Representaremos uma ALU desse tipo com o rótulo Soma, como na Figura 4.5, para indicar que ela se tornou permanentemente um somador e não pode realizar as outras funções da ALU.
FIGURA 4.5 Dois elementos de estado são necessários para armazenar e acessar instruções, e um somador é necessário para calcular o endereço da próxima instrução. Os elementos de estado são a memória de instruções e o contador de programa. A memória de instruções só precisa fornecer acesso de leitura porque o caminho de dados não escreve instruções. Como a memória de instruções apenas é lida, nós a tratamos como lógica combinatória: a saída em qualquer momento reflete o conteúdo do local especificado pela entrada de endereço, e nenhum sinal de controle de leitura é necessário. (Precisaremos escrever na memória de instruções quando carregarmos o programa; isso não é difícil de incluir e o ignoramos em favor da simplicidade.) O contador de programa é um registrador de 32 bits que é escrito no final de cada ciclo de clock e, portanto, não precisa de um sinal de controle de escrita. O somador é uma ALU configurada para sempre realizar a adição das suas duas entradas de 32 bits e colocar o resultado em sua saída.
contador de programa (PC) O registrador que contém o endereço da instrução do programa sendo executado. Para executar qualquer instrução, precisamos começar buscando a instrução na memória. A fim de preparar para executar a próxima instrução, também temos de incrementar o contador de programa de modo que aponte para a próxima instrução, 4 bytes depois. A Figura 4.6 mostra como combinar os três elementos da Figura 4.5 para formar um caminho de dados que busca instruções e incrementa o PC de modo a obter o endereço da próxima instrução sequencial.
FIGURA 4.6 Uma parte do caminho de dados usada para buscar instruções e incrementar o contador do programa. A instrução buscada é usada por outras partes do caminho de dados.
Agora, vamos considerar as instruções de formato R (Figura 2.20). Todas elas leem dois registradores, realizam uma operação na ALU com o conteúdo dos registradores e escrevem o resultado em um registrador. Chamamos essas instruções de instruções tipo R ou instruções lógicas ou aritméticas (já que elas realizam operações lógicas ou aritméticas). Essa classe de instrução inclui add, sub, AND, OR e slt, que foram apresentadas no Capítulo 2. Lembre-se de que um caso típico desse tipo de instrução é add $t1, $t2, $t3, que lê $t2 e $t3 e escreve em $t1. Os registradores de uso geral de 32 bits do processador são armazenados em uma estrutura chamada banco de registradores. Um banco de registradores é uma coleção de registradores em que qualquer registrador pode ser lido ou escrito especificando o número do registrador no banco. O banco de registradores contém o estado dos registradores do computador. Além disso,
precisaremos que uma ALU opere nos valores lidos dos registradores.
banco de registradores Um elemento de estado que consiste em um grupo de registradores que podem ser lidos e escritos fornecendo um número de registrador a ser acessado. Devido às instruções de formato R terem três operandos de registrador, precisaremos ler duas palavras de dados do banco de registradores e escrever uma palavra de dados no banco de registradores para cada instrução. A fim de que cada palavra de dados seja lida dos registradores, precisamos de uma entrada no banco de registradores que especifique o número do registrador a ser lido e uma saída do banco de registradores que conduzirá o valor lido dos registradores. Para escrever uma palavra de dados, precisaremos de duas entradas: uma para especificar o número do registrador a ser escrito e uma para fornecer os dados a serem escritos no registrador. O banco de registradores sempre gera como saída o conteúdo de quaisquer números de registrador que estejam nas entradas Registrador de leitura. As escritas, entretanto, são controladas pelo sinal de controle de escrita, que precisa estar ativo para que uma escrita ocorra na transição do clock. A Figura 4.7a mostra o resultado; precisamos de um total de quatro entradas (três para números de registrador e uma para dados) e duas saídas (ambas para dados). As entradas de número de registrador possuem 5 bits de largura para especificar um dos 32 registradores (32 = 25), enquanto a entrada de dados e os dois barramentos de saída de dados possuem 32 bits de largura cada um.
FIGURA 4.7 Os dois elementos necessários para
implementar operações para a ALU no formato R são o banco de registradores e a ALU. O banco de registradores contém todos os registradores e possui duas portas para leitura e uma porta para escrita. O projeto dos bancos de registradores de várias portas é discutido na Seção B.8 do Apêndice B. O banco de registradores sempre gera como saídas os conteúdos dos registradores correspondentes às entradas Registrador de leitura nas saídas; nenhuma outra entrada de controle é necessária. Ao contrário, uma escrita em um registrador precisa ser explicitamente indicada ativando o sinal de controle de escrita. Lembre-se de que as escritas são acionadas por transição, de modo que todas as entradas de escrita (por exemplo, o valor a ser escrito, o número do registrador e o sinal de controle de escrita) precisam ser válidas na transição do clock. Como as escritas no banco de registradores são acionadas por transição, nosso projeto pode ler e escrever sem problemas no mesmo registrador dentro de um ciclo de clock: a leitura obterá o valor escrito em um ciclo de clock anterior, enquanto o valor escrito estará disponível para uma leitura em um ciclo de clock subsequente. Todas as entradas com o número do registrador para o banco de registradores possuem 5 bits de largura, enquanto as linhas com os valores de dados possuem 32 bits de largura. A operação a ser realizada pela ALU é controlada com o sinal de operação da ALU, que terá largura de 4 bits, usando a ALU projetada no Apêndice B. Em breve, usaremos a saída de detecção Zero da ALU para implementar desvios. A saída de overflow não será necessária até a Seção 4.9, quando discutiremos as exceções; até lá, elas serão omitidas.
A Figura 4.7b mostra a ALU, que usa duas entradas de 32 bits e produz um resultado de 32 bits, bem como um sinal de 1 bit se o resultado for 0. O sinal de controle de quatro bits da ALU é descrito em detalhes no Apêndice B; examinaremos o controle da ALU brevemente quando precisarmos saber como defini-lo. A seguir, considere as instruções MIPS load word e store word, que possuem o formato lw $t1,offset_value($t2) ou sw $t1,offset_value($t2). Essas instruções calculam um endereço de memória somando o registrador de base, que é $t2, com o campo offset de 16 bits com sinal contido na instrução. Se a instrução for um store, o valor a ser armazenado também precisará ser lido do banco de registradores em que reside, em $t1. Se a instrução for um load, o valor lido da memória precisará ser escrito no banco de registradores no
registrador especificado, que é $t1. Consequentemente, precisaremos do banco de registradores e da ALU da Figura 4.7.
banco de registradores Um elemento de estado que consiste em um grupo de registradores que podem ser lidos e escritos fornecendo um número de registrador a ser acessado. Além disso, precisaremos de uma unidade a fim de estender o sinal do campo offset de 16 bits da instrução para um valor com sinal de 32 bits, e de uma unidade de memória da para ler ou escrever. A memória de dados precisa ser escrita com instruções store; portanto, ela tem sinais de controle de leitura e escrita, uma entrada de endereço e uma entrada para os dados serem escritos na memória. A Figura 4.8 mostra esses dois elementos.
FIGURA 4.8 As duas unidades necessárias para implementar loads e stores, além do banco de registradores e da ALU da Figura 4.7, são a unidade de memória de dados e a unidade de extensão de sinal. A unidade de memória é um elemento de estado com entradas para os endereços e os dados de escrita, e uma única saída para o resultado da leitura. Existem controles de leitura e escrita separados, embora apenas um deles possa estar ativado em qualquer clock específico. A unidade de memória precisa de um
sinal de leitura, já que, diferente do banco de registradores, ler o valor de um endereço inválido pode causar problemas, como veremos no Capítulo 5. A unidade de extensão de sinal possui uma entrada de 16 bits que tem o seu sinal estendido para que um resultado de 32 bits apareça na saída (Capítulo 2). Consideramos que a memória de dados é acionada por transição para as escritas. Na verdade, os chips de memória padrão possuem um sinal “write enable” que é usado para escritas. Embora o write enable não seja acionado por transição, nosso projeto acionado por transição poderia facilmente ser adaptado para funcionar com chips de memória reais. Consulte a Seção B.8 do Apêndice B para ver uma discussão mais detalhada de como funcionam os chips de memória reais.
A instrução beq possui três operandos, dois registradores comparados para igualdade e um offset de 16 bits para calcular o endereço de destino do desvio relativo ao endereço da instrução desvio. Sua forma é beq $t1,$t2,offset. Para implementar essa instrução, precisamos calcular o endereço de destino somando o campo offset estendido com sinal da instrução com o PC. Há dois detalhes na definição de instruções de desvio (Capítulo 2) para os quais precisamos prestar atenção: ▪ O conjunto de instruções especifica que a base para o cálculo do endereço de desvio é o endereço da instrução seguinte ao desvio. Como calculamos PC + 4 (o endereço da próxima instrução) no caminho de dados para busca de instruções, é fácil usar esse valor como a base para calcular o endereço de destino do desvio. ▪ A arquitetura também diz que o campo offset é deslocado 2 bits para a esquerda, de modo que é um offset de uma palavra; esse deslocamento aumenta a faixa efetiva do campo offset por um fator de quatro vezes.
endereço de destino do desvio O endereço especificado em um desvio, que se torna o novo contador do programa (PC) se o desvio for tomado. Na arquitetura MIPS, o destino do desvio é dado pela soma do campo offset da instrução e o endereço da instrução seguinte ao desvio. Para lidar com a última complicação, precisaremos deslocar o campo offset de dois bits.
Além de calcular o endereço de destino do desvio, também precisamos determinar se a próxima instrução é a instrução que acompanha sequencialmente ou a instrução no endereço de destino do desvio. Quando a condição é verdadeira (isto é, os operandos são iguais), o endereço de destino do desvio se torna o novo PC e dizemos que o desvio é tomado. Se os operandos não forem iguais, o PC incrementado deve substituir o PC atual (exatamente como para qualquer outra instrução normal); nesse caso, dizemos que o desvio é não tomado.
desvio tomado Um desvio em que a condição de desvio é satisfeita, e o contador do programa (PC) se torna o destino do desvio. Todos os desvios incondicionais são desvios tomados.
desvio não tomado Um desvio em que a condição de desvio é falsa e o contador do programa (PC) se torna o endereço da instrução que acompanha sequencialmente o desvio. Portanto, o caminho de dados de desvio precisa realizar duas operações: calcular o endereço de destino do desvio e comparar o conteúdo do registrador. (Os desvios também afetam a parte da busca de instrução do caminho de dados, como veremos em breve.) A Figura 4.9 mostra a estrutura do segmento do caminho de dados que lida com os desvios. Para calcular o endereço de destino do desvio, o caminho de dados de desvio inclui uma unidade de extensão de sinal, exatamente como a da Figura 4.8, e um somador. Para realizar a comparação, precisamos usar o banco de registradores mostrado na Figura 4.7a a fim de fornecer os dois operandos (embora não precisemos escrever no banco de registradores). Além disso, a comparação pode ser feita usando a ALU que projetamos no Apêndice B. Como essa ALU fornece um sinal de saída que indica se o resultado era 0, podemos enviar os dois operandos de registrador para a ALU com o conjunto de controle de modo a fazer uma subtração. Se o sinal Zero da ALU estiver ativo, sabemos que os dois valores são iguais. Embora a saída de Zero sempre sinalize quando o resultado é 0, nós a estaremos usando apenas para implementar o teste de igualdade dos desvios. Mais adiante, mostraremos exatamente como conectar os sinais de controle da ALU para uso
no caminho de dados.
FIGURA 4.9 O caminho de dados para um desvio usa a ALU a fim de avaliar a condição de desvio e um somador separado para calcular o destino do desvio como a soma do PC incrementado e os 16 bits mais baixos da instrução com sinal estendido (o deslocamento do desvio), deslocados de 2 bits para a esquerda. A unidade rotulada como Deslocamento de 2 à esquerda é simplesmente um direcionamento dos sinais entre entrada e saída que acrescenta 00bin à extremidade de baixa ordem do campo offset com sinal estendido; nenhum hardware de deslocamento real é necessário, já que a quantidade de “deslocamento” é constante. Como sabemos que o offset teve o sinal dos seus 16 bits estendido, o deslocamento irá descartar apenas “bits de sinal”. A lógica de controle é usada para decidir se o PC incrementado ou o destino do desvio deve substituir o PC, com base na saída Zero da ALU.
A instrução jump funciona substituindo os 28 bits menos significativos do PC
pelos 26 bits menos significativos da instrução deslocados de 2 bits à esquerda. Esse deslocamento é realizado simplesmente concatenando 00 ao offset do jump, como descrito no Capítulo 2.
Detalhamento No conjunto de instruções MIPS, os desvios são atrasados, isso significa que a instrução imediatamente posterior ao desvio é sempre executada, independentemente da condição de desvio ser verdadeira ou falsa. Quando a condição é falsa, a execução se parece com um desvio normal. Quando a condição é verdadeira, um desvio atrasado primeiro executa a instrução imediatamente posterior ao desvio na ordem sequencial antes de desviar para o endereço de destino do desvio. A motivação para os desvios atrasados surge de como o pipelining afeta os desvios (Seção 4.8). Para simplificar, geralmente ignoramos os desvios atrasados neste capítulo e implementamos uma instrução beq como não sendo atrasado.
desvio atrasado Um tipo de desvio em que a instrução imediatamente seguinte ao desvio é sempre executada, independente de a condição do desvio ser verdadeira ou falsa.
Criando um caminho de dados simples Agora que examinamos os componentes do caminho de dados necessários para as classes de instrução individualmente, podemos combiná-los em um único caminho de dados e acrescentar o controle para completar a implementação. O caminho de dados mais simples pode tentar executar todas as instruções em um único ciclo de clock. Isso significa que nenhum recurso do caminho de dados pode ser usado mais de uma vez por instrução e, portanto, qualquer elemento necessário mais de uma vez precisa ser duplicado. Então, precisamos de uma memória para instruções separada da memória para dados. Embora algumas unidades funcionais precisem ser duplicadas, muitos dos elementos podem ser compartilhados por diferentes fluxos de instrução. Para compartilhar um elemento do caminho de dados entre duas classes de instrução diferentes, talvez tenhamos de permitir múltiplas conexões com a entrada de um elemento usando um multiplexador e um sinal de controle para
selecionar entre as múltiplas entradas.
Construindo um caminho de dados Exemplo As operações do caminho de dados das instruções lógicas e aritméticas (ou tipo R) e das instruções de acesso à memória são muito semelhantes. As principais diferenças são as seguintes: ▪ As instruções lógicas e aritméticas usam a ALU com as entradas vindas de dois registradores. As instruções de acesso à memória também podem usar a ALU para fazer o cálculo do endereço, embora a segunda entrada seja o campo offset de 16 bits com sinal estendido da instrução. ▪ O valor armazenado em um registrador de destino vem da ALU (para uma instrução tipo R) ou da memória (para um load). Mostre como construir um caminho de dados para a parte operacional das instruções de referência à memória e instruções lógicas e aritméticas, que use um único banco de registradores e uma única ALU para manipular os dois tipos de instrução, incluindo quaisquer multiplexadores necessários.
Resposta Para criar um caminho de dados com apenas um único banco de registradores e uma única ALU, precisamos suportar duas origens diferentes para a segunda entrada da ALU, bem como duas origens diferentes para os dados armazenados no banco de registradores. Portanto, um multiplexador é colocado na entrada da ALU e outro na entrada de dados para o banco de registradores. A Figura 4.10 mostra a parte operacional do caminho de dados combinado.
FIGURA 4.10 O caminho de dados para as instruções de acesso à memória e as instruções tipo R. Este exemplo mostra como um único caminho de dados pode ser montado, a partir das partes nas Figuras 4.7 e 4.8 acrescentando multiplexadores. Dois multiplexadores são necessários, como descrito no exemplo.
Agora, podemos combinar todas as partes de modo a criar um caminho de dados simples para a arquitetura do núcleo MIPS incluindo um caminho de dados para busca de instruções (Figura 4.6), o caminho de dados das instruções tipo R e de acesso à memória (Figura 4.10) e o caminho de dados para desvios (Figura 4.9). A Figura 4.11 mostra o caminho de dados que obtemos compondo as partes separadas. A instrução de desvio usa a ALU principal para comparação dos operandos registradores, de modo que precisamos manter o somador da Figura 4.9, a fim de calcular o endereço de destino do desvio. Um multiplexador adicional é necessário para selecionar o endereço de instrução seguinte (PC + 4) ou o endereço de destino do desvio a ser escrito no PC.
FIGURA 4.11 O caminho de dados simples para a arquitetura MIPS combina os elementos necessários para diferentes classes de instrução. Os componentes vêm das Figuras 4.6, 4.9 e 4.10. Este caminho de dados pode executar as instruções básicas (load-store word, operações da ALU e desvios) em um único ciclo de clock. Apenas um multiplexador adicional é necessário para integrar os desvios. O suporte para jumps será incluído mais tarde.
Agora que completamos este caminho de dados simples, podemos acrescentar a unidade de controle. A unidade de controle precisa ser capaz de ler entradas e gerar um sinal de escrita para cada elemento de estado, o controle seletor de cada multiplexador e o controle da ALU. O controle da ALU é diferente de várias maneiras e será útil projetá-lo primeiro, antes de projetarmos o restante da unidade de controle.
Verifique você mesmo I. Qual das seguintes afirmativas é correta para uma instrução load? Consulte a Figura 4.10. a. MemtoReg deve ser definido para fazer com que os dados da memória sejam enviados ao banco de registradores.
b. MemtoReg deve ser definido para fazer com que o registrador de destino correto seja enviado ao banco de registradores. c. Não precisamos nos importar com MemtoReg para loads. II. O caminho de dados de ciclo único descrito conceitualmente nesta seção precisa ter memórias de instrução e dados separadas, porque: a. os formatos dos dados e das instruções são diferentes no MIPS, e, portanto, memórias diferentes são necessárias. b. ter memórias separadas é menos dispendioso. c. o processador opera em um ciclo e não pode usar uma memória de porta simples para dois acessos diferentes dentro desse ciclo.
4.4. Um esquema de implementação simples Nesta seção, veremos o que poderia ser considerado a implementação mais simples possível do nosso subconjunto MIPS. Construímos essa implementação simples usando o caminho de dados da última seção e acrescentando uma função de controle simples. Essa implementação simples cobre as instruções load word (lw), store word (sw), branch equal (beq) e as instruções lógicas e aritméticas add, sub, AND, OR e set on less than. Posteriormente, desenvolveremos o projeto para incluir uma instrução jump (j).
O controle da ALU A ALU MIPS no Apêndice B define as 6 combinações a seguir das quatro entradas de controle: Linhas de controle da ALU
Função
0000
AND
0001
OR
0010
add
0110
subtract
0111
set on less than
1100
NOR
Dependendo da classe de instrução, a ALU precisará realizar uma dessas cinco primeiras funções. (NOR é necessária para outras partes do conjunto de instruções MIPS não encontradas no subconjunto que estamos implementando.) Para as instruções load word e store word, usamos a ALU para calcular o
endereço de memória por adição. Para instruções tipo R, a ALU precisa realizar uma das cinco ações (AND, OR, subtract, add ou set on less than), dependendo do valor do campo funct (ou function – função) de 6 bits nos bits menos significativos da instrução (Capítulo 2). Para branch equal, a ALU precisa realizar uma subtração. Podemos gerar a entrada do controle da ALU de 4 bits usando uma pequena unidade de controle que tenha como entradas o campo funct da instrução e um campo control de 2 bits, que chamamos de ALUOp. ALUOp indica se a operação a ser realizada deve ser add (00) para loads e stores, subtract (01) para beq ou determinada pela operação codificada no campo funct (10). A saída da unidade de controle da ALU é um sinal de 4 bits que controla diretamente a ALU gerando uma das combinações de 4 bits mostradas anteriormente. Na Figura 4.12, mostramos como definir as entradas do controle da ALU com base no controle ALUOp de 2 bits e no código de função de 6 bits. Mais adiante neste capítulo, veremos como os bits de ALUOp são gerados na unidade de controle principal.
FIGURA 4.12 A forma como os bits de controle da ALU são definidos, dependendo dos bits de controle de ALUOp e dos diferentes códigos de função para as instruções tipo R. O opcode, que aparece na primeira coluna, determina a definição dos bits de ALUOp. Todas as codificações são mostradas em binário. Observe que quando o código de ALUOp é 00 ou 01, a ação da ALU desejada não depende do campo de código de função; nesse caso, dizemos que “não nos importamos” (don’t care) com o valor do código de função e o campo funct aparece como XXXXXX. Quando o valor de ALUOp é 10, então o código de função é usado para definir a entrada do controle da ALU. Veja o Apêndice B.
Esse estilo de usar vários níveis de decodificação — ou seja, a unidade de controle principal gera os bits de ALUOp, que, então, são usados como entrada para o controle da ALU que gera os sinais reais para controlar a ALU — é uma técnica de implementação comum. Usar níveis múltiplos de controle pode reduzir o tamanho da unidade de controle principal. Usar várias unidades de controle menores também pode aumentar a velocidade da unidade de controle. Essas otimizações são importantes, pois a velocidade da unidade de controle normalmente é essencial para o tempo de ciclo de clock. Há várias maneiras diferentes de implementar o mapeamento do campo ALUOp de 2 bits e do campo funct de 6 bits para os 3 bits de controle de operação da ALU. Como apenas um pequeno número dos 64 valores possíveis do campo funct são de interesse e o campo funct é usado apenas quando os bits de ALUOp são iguais a 10, podemos usar uma pequena lógica que reconhece o subconjunto dos valores possíveis e faz a definição correta dos bits de controle da ALU. Como uma etapa no projeto dessa lógica, é útil criar uma tabela verdade para as combinações interessantes do campo de código funct e dos bits de ALUOp, como fizemos na Figura 4.13; essa tabela verdade mostra como o controle da ALU de 4 bits é definido, de acordo com esses dois campos de entrada. Como a tabela verdade inteira é muito grande (28 = 256 entradas) e não nos importamos com o valor do controle da ALU para muitas dessas combinações de entrada, mostramos apenas as entradas para as quais o controle da ALU precisa ter um valor específico. Em todo este capítulo, usaremos essa prática de mostrar apenas as entradas da tabela verdade que precisam ser declaradas e não mostrar as que estão zeradas ou que não nos interessam.
FIGURA 4.13 A tabela verdade para os 4 bits de controle da ALU (chamados Operação). As entradas são ALUOp e o campo de código de função. Apenas
as entradas para as quais o controle da ALU é ativado são mostradas. Algumas entradas don’t care foram incluídas. Por exemplo, como ALUOp não usa a codificação 11, a tabela verdade pode conter entradas 1X e X1, em vez de 10 e 01. Além disso, quando o campo funct é usado, os dois primeiros bits (F5 e F4) dessas instruções são sempre 10; portanto, eles são termos don’t care e são substituídos por XX na tabela verdade.
tabela verdade Pela lógica, uma representação de uma operação lógica listando todos os valores das entradas e em seguida, em cada caso, mostrando quais deverão ser as saídas resultantes. Como, em muitos casos, não nos interessamos pelos valores de algumas das entradas e para mantermos as tabelas compactas, também incluímos termos don’t care. Um termo don’t care nessa tabela verdade (representado por um X em uma coluna de entrada) indica que a saída não depende do valor da entrada correspondente a essa coluna. Por exemplo, quando os bits de ALUOp são 00, como na primeira linha da tabela na Figura 4.13, sempre definimos o controle da ALU em 0010, independente do código funct. Nesse caso, então, as entradas do código funct serão don’t care nessa linha da tabela verdade. Depois, veremos exemplos de outro tipo de termo don’t care. Se você não estiver familiarizado com o conceito de termos don’t care, veja o Apêndice B para obter mais informações.
termo don’t care Um elemento de uma função lógica em que a saída não depende dos valores de todas as entradas. Os termos don’t care podem ser especificados de diversas maneiras. Uma vez construída a tabela, ela pode ser otimizada e depois transformada em portas lógicas. Esse processo é completamente mecânico.
Projetando a unidade de controle principal Agora que descrevemos como projetar uma ALU que usa o código de função e
um sinal de 2 bits como suas entradas de controle, podemos voltar a considerar o restante do controle. Para começar esse processo, vamos identificar os campos de uma instrução e as linhas de controle necessárias para o caminho de dados construído na Figura 4.11. A fim de entender como conectar os campos de uma instrução com o caminho de dados, é útil examinar os formatos das três classes de instrução: as instruções tipo R, as instruções de desvio e as instruções loadstore. A Figura 4.14 mostra esses formatos.
FIGURA 4.14 As três classes de instrução (tipo R, load/store e desvio) usam dois formatos de instrução diferentes. As instruções jump usam outro formato, que será discutido em breve. (a) Formato de instrução para instruções tipo R, as quais possuem todas opcode 0. Essas instruções possuem três registradores como operandos: rs, rt e rd. Os campos rs e rt são origens e rd é o destino. A função da ALU está no campo funct e é decodificada pelo projeto de controle da ALU da seção anterior. As instruções tipo R que implementamos são add, sub, AND, OR e slt. O campo shamt é usado apenas para deslocamentos; nós o ignoraremos neste capítulo. (b) Formato de instrução para instruções load (opcode = 35dec) e store (opcode = 43dec). O registrador rs é o registrador de base adicionado ao campo address de 16 bits de modo a formar o endereço de memória. Com os loads, rt é o registrador de destino para o valor lido. Com stores, rt é o registrador de origem cujo valor deve ser armazenado na memória. (c) Formato de instrução para branch equal (opcode = 4). Os registradores rs e rt são os registradores de origem que são comparados por igualdade. O campo address de 16 bits tem seu sinal estendido, é deslocado e somado ao PC + 4 para calcular o endereço de
destino do desvio.
Existem várias observações importantes sobre esses formatos de instrução em que nos basearemos: ▪ Campo op, também chamado opcode no Capítulo 2, está sempre contido nos bits 31:26. Iremos nos referir a esse campo como Op[5:0]. ▪ Os dois registradores a serem lidos sempre são especificados pelos campos rs e rt, nas posições 25:21 e 20:16. Isso é verdade para as instruções tipo R, branch equal e store. ▪ Registrador de base para as instruções load e store está sempre nas posições de bit 25:21 (rs). ▪ Offset de 16 bits para branch equal, load e store está sempre nas posições 15:0. ▪ Registrador de destino está em um de dois lugares. Para um load, ele está nas posições 20:16 (rt), enquanto para uma instrução tipo R, ele está nas posições 15:11 (rd). Portanto, precisaremos incluir um multiplexador a fim de selecionar que campo da instrução será usado para indicar o número de registrador a ser escrito.
opcode O campo que denota a operação e o formato de uma instrução. A vantagem do primeiro princípio de projeto do Capítulo 2 — a simplicidade favorece a regularidade — aparece aqui na especificação do controle. Usando essas informações, podemos acrescentar os rótulos de instrução e o multiplexador extra (para a entrada Escreve registrador do banco de registradores) no caminho de dados simples. A Figura 4.15 mostra essas adições, além do bloco de controle da ALU, os sinais de escrita para elementos de estado, o sinal de leitura para a memória de dados e os sinais de controle para os multiplexadores. Como todos os multiplexadores possuem duas entradas, cada um deles requer uma única linha de controle.
FIGURA 4.15 O caminho de dados da Figura 4.11 com todos os multiplexadores necessários e todas as linhas de controle identificadas. As linhas de controle são mostradas em tons de cinza O bloco de controle da ALU também foi acrescentado. O PC não exige um controle de escrita, já que ele é escrito uma vez no fim de cada ciclo de clock; a lógica de controle de desvio determina se ele é escrito com o PC incrementado ou o endereço de destino do desvio.
A Figura 4.15 mostra sete linhas de controle de um único bit mais o sinal de controle ALUOp de 2 bits. Já definimos como o sinal de controle ALUOp funciona e é útil definir o que fazem os outros sete sinais de controle informalmente antes de determinarmos como definir esses sinais de controle durante a execução da instrução. A Figura 4.16 descreve a função dessas sete linhas de controle.
FIGURA 4.16 O efeito de cada um dos sete sinais de controle. Quando o controle de um bit de largura, para um multiplexador com duas entradas, está ativo, o multiplexador seleciona a entrada correspondente a 1. Caso contrário, se o controle não estiver ativo, o multiplexador seleciona a entrada 0. Lembre-se de que todos os elementos de estado têm o clock como uma entrada implícita e que o clock é usado para controlar escritas. O clock nunca vem externamente para um elemento de estado, já que isso pode criar problemas de sincronização. (Veja o Apêndice B para obter mais detalhes sobre esse problema.)
Agora que examinamos a função de cada um dos sinais de controle, podemos ver como defini-los. A unidade de controle pode definir todos menos um dos sinais de controle unicamente com base no campo opcode da instrução. A exceção é a linha de controle PCScrPCScr. Essa linha de controle deve ser ativada se a instrução for branch on equal (uma decisão que a unidade de controle pode tomar) e a saída Zero da ALU, usada para comparação de igualdade, for verdadeira. Para gerar o sinal PCScrPCScr, precisaremos realizar um AND de um sinal da unidade de controle, que chamamos Branch, com o sinal Zero da ALU. Esses nove sinais de controle (sete da Figura 4.16 e dois para ALUOp) podem agora ser definidos baseados nos seis sinais de entrada da unidade de controle, que são os bits de opcode 31 a 26. A Figura 4.17 mostra o caminho de dados
com a unidade de controle e os sinais de controle.
FIGURA 4.17 O caminho de dados simples com a unidade de controle. A entrada para a unidade de controle é o campo opcode de 6 bits da instrução. As saídas da unidade de controle consistem em três sinais de 1 bit usados para controlar multiplexadores (RegDst, ALUScrALUScr e MemtoReg), três sinais para controlar leituras e escritas no banco de registradores e na memória de dados (WriteReg, ReadMem e WriteMem), um sinal de 1 bit usado na determinação de um possível desvio (Branch) e um sinal de controle de 2 bits para a ALU (ALUOp). Uma porta AND é usada de modo a combinar o sinal de controle de desvio com a saída Zero da ALU; a saída da porta AND controla a seleção do próximo PC. Observe que PCScr é agora um sinal derivado, em vez de um sinal vindo diretamente da unidade de controle. Portanto, descartamos o nome do sinal nas próximas figuras.
Antes de tentarmos escrever um conjunto de equações ou uma tabela verdade
para a unidade de controle, será útil definir a função de controle informalmente. Como a definição das linhas de controle depende apenas do opcode, definimos se cada sinal de controle deve ser 0, 1 ou don’t care (X) para cada um dos valores de opcode. A Figura 4.18 descreve como os sinais de controle devem ser definidos para cada opcode; essas informações seguem diretamente das Figuras 4.12, 4.16 e 4.17.
FIGURA 4.18 A definição das linhas de controle é completamente determinada pelos campos opcode da instrução. A primeira linha da tabela corresponde às instruções formato R (add, sub, AND, OR e slt). Para todas essas instruções, os campos registradores de origem são rs e rt, e o campo registrador de destino é rd; isso especifica como os sinais ALUScr e RegDst são definidos. Além disso, uma instrução tipo R escreve em um registrador (WriteReg = 1), mas não escreve ou lê a memória de dados. Quando o sinal de controle Branch é 0, o PC é incondicionalmente substituído por PC + 4; caso contrário, o PC é substituído pelo destino do desvio se a saída Zero da ALU também está ativa. O campo ALUOp para as instruções tipo R é definido como 10 a fim de indicar que o controle da ALU deve ser gerado do campo funct. A segunda e a terceira linhas dessa tabela fornecem as definições dos sinais de controle para lw e sw. Esses campos ALUScr e ALUOp são definidos para realizar o cálculo do endereço. ReadMem e WriteMem são definidos para realizar o acesso à memória. Finalmente, RegDst e WriteReg são definidos para que um load faça o resultado ser armazenado no registrador rt. A instrução branch é semelhante à operação no formato R, já que ela envia os registradores rs e rt para a ALU. O campo ALUOp para um desvio é definido como uma subtração (controle da ALU = 01), usada para testar a igualdade. Repare que o campo MemtoReg é irrelevante quando o sinal WriteReg é 0: como o registrador não está sendo escrito, o valor dos dados na entrada Dados para escrita do banco de registradores não é usado. Portanto, a entrada MemtoReg nas duas últimas linhas da tabela é substituída por X (don’t care). Os don’t care também podem ser adicionados a RegDst quando WriteReg é 0. Esse tipo de don’t care precisa ser acrescentado pelo projetista, uma
vez que ele depende do conhecimento de como o caminho de dados funciona.
Operação do caminho de dados Com as informações contidas nas Figuras 4.16 e 4.18, podemos projetar a lógica da unidade de controle. Antes de fazer isso, porém, vejamos como cada instrução usa o caminho de dados. Nas próximas figuras, mostramos o fluxo das três classes de instrução diferentes por meio do caminho de dados. Os sinais de controle ativos e os elementos do caminho de dados ativos são destacados em cada uma das figuras. Observe que um multiplexador cujo controle é 0 tem uma ação definida, mesmo se sua linha de controle não estiver destacada. Sinais de controle de vários bits são destacados se qualquer sinal constituinte estiver ativo. A Figura 4.19 mostra a operação do caminho de dados para uma instrução tipo R, como add $t1,$t2,$t3. Embora tudo ocorra em um ciclo de clock, podemos pensar em quatro etapas para executar a instrução; essas etapas são ordenadas pelo fluxo da informação: 1. A instrução é buscada e o PC é incrementado.
FIGURA 4.19 O caminho de dados em operação para uma instrução tipo R como add $t1,$t2,$t3. As linhas de controle, as unidades do caminho de dados e as conexões que estão ativas aparecem destacadas.
2. Dois registradores, $t2 e $t3, são lidos do banco de registradores, e a unidade de controle principal calcula a definição das linhas de controle também durante essa etapa. 3. A ALU opera nos dados lidos do banco de registradores, usando o código de função (bits 5:0, que é o campo funct, da instrução) para gerar a função da ALU. 4. O resultado da ALU é escrito no banco de registradores usando os bits 15:11 da instrução para selecionar o registrador de destino ($t1). Da mesma forma, podemos ilustrar a execução de um load word, como
em um estilo semelhante à Figura 4.19. A Figura 4.20 mostra as unidades funcionais ativas e as linhas de controle ativas para um load. Podemos pensar em uma instrução load como operando em cinco etapas (semelhante ao tipo R executado em quatro): 1. Uma instrução é buscada da memória de instruções e o PC é incrementado.
FIGURA 4.20 O caminho de dados em operação para uma instrução load. As linhas de controle, as unidades do caminho de dados e as conexões ativas aparecem destacadas. Uma instrução store operaria de maneira muito semelhante. A principal diferença seria que o controle da memória indicaria uma escrita em vez de uma leitura, a segunda leitura do valor de um registrador seria usada para os dados a serem armazenados e a operação de escrita do valor da memória de dados no banco de registradores não ocorreria.
2. Um valor de registrador ($t2) é lido do banco de registradores. 3. A ALU calcula a soma do valor lido do banco de registradores com os 16 bits menos significativos com sinal estendido da instrução (offset). 4. A soma da ALU é usada como o endereço para a memória de dados. 5. Os dados da unidade de memória são escritos no banco de registradores; o registrador de destino é fornecido pelos bits 20:16 da instrução ($t1). Finalmente, podemos mostrar a operação da instrução branch-on-equal, como beq $t1,$t2,offset, da mesma maneira. Ela opera de forma muito parecida com uma instrução de formato R, mas a saída da ALU é usada para determinar se o PC é escrito com PC + 4 ou o endereço de destino do desvio. A Figura 4.21 mostra as quatro etapas da execução: 1. Uma instrução é buscada da memória de instruções e o PC é incrementado.
FIGURA 4.21 O caminho de dados em operação para uma instrução branch-on-equal. As linhas de controle, as unidades do caminho de dados e as conexões que estão ativas aparecem destacadas. Após usar o
banco de registradores e a ALU para realizar a comparação, a saída Zero é usada na seleção do próximo contador de programa dentre os dois candidatos.
2. Dois registradores, $t1 e $t2, são lidos do banco de registradores. 3. A ALU realiza uma subtração dos valores de dados lidos do banco de registradores. O valor de PC + 4 é somado aos 16 bits menos significativos com sinal estendido (offset) deslocados de dois para a esquerda; o resultado é o endereço de destino do desvio. 4. O resultado Zero da ALU é usado para decidir qual resultado do somador deve ser armazenado no PC. Finalizando o controle Agora que vimos como as instruções operam em etapas, vamos continuar com a implementação do controle. A função de controle pode ser definida com precisão usando o conteúdo da Figura 4.18. As saídas são as linhas de controle, e a entrada é o campo opcode de 6 bits, Op [5:0]. Portanto, podemos criar uma tabela verdade para cada uma das saídas com base na codificação binária dos opcodes. A Figura 4.22 mostra a lógica na unidade de controle como uma grande tabela verdade que combina todas as saídas e que usa os bits de opcode como entradas. Ela especifica completamente a função de controle, e podemos implementá-la diretamente em portas lógicas de uma maneira automatizada.
FIGURA 4.22 A função de controle para a implementação de ciclo único simples é completamente especificada por essa tabela verdade. A parte superior da tabela fornece combinações de sinais de entrada que correspondem aos quatro opcodes, um por coluna, que determinam as definições de saída do controle. (Lembre-se de que Op [5:0] corresponde aos bits 31:26 da instrução, que é o campo op.) A parte inferior da tabela fornece as saídas para cada um dos quatro opcodes. Portanto, a saída WriteReg é ativada para duas combinações diferentes das entradas. Se considerarmos apenas os quatro opcodes mostrados nessa tabela, então, poderemos simplificar a tabela verdade usando don’t care na parte da entrada. Por exemplo, podemos detectar uma instrução no formato R com a expressão [see original, pg 269], uma vez que isso é suficiente para distinguir as instruções no formato R das instruções lw, sw e beq. Não tiramos vantagem dessa simplificação, já que o restante dos opcodes MIPS é usado em uma implementação completa.
Agora que temos uma implementação de ciclo único da maioria dos conjuntos de instruções MIPS básico, vamos acrescentar a instrução jump para mostrar como o caminho de dados básico e o controle podem ser estendidos ao lidar com outras instruções no conjunto de instruções.
implementação de ciclo único Também chamada de implementação de ciclo de clock único. Uma
implementação em que uma instrução é executada em um único ciclo de clock.
Implementando jumps Exemplo A Figura 4.17 mostra a implementação de várias instruções vistas no Capítulo 2. Uma classe de instruções ausente é a da instrução jump. Estenda o caminho de dados e o controle da Figura 4.17 para incluir a instrução jump. Descreva como definir quaisquer novas linhas de controle.
Resposta A instrução jump, mostrada na Figura 4.23, se parece um pouco com uma instrução branch, mas calcula o PC de destino de maneira diferente e não é condicional. Como um branch, os 2 bits menos significativos de um endereço jump são sempre 00bin. Os próximos 26 bits menos significativos desse endereço de 32 bits vêm do campo imediato de 26 bits na instrução. Os 4 bits superiores do endereço que deve substituir o PC vêm do PC da instrução jump mais 4. Portanto, podemos implementar um jump armazenando no PC a concatenação de: ▪ os 4 bits superiores do PC atual + 4 (esses são bits 31:28 do endereço da instrução imediatamente seguinte); ▪ campo de 26 bits imediato da instrução jump; ▪ os bits 00bin.
FIGURA 4.23 Formato de instrução para a instrução jump (opcode = 2). O endereço de destino para uma instrução jump é formado pela concatenação dos 4 bits superiores do PC atual + 4, com o campo endereço de 26 bits na instrução jump e pela adição de 00 como os dois bits menos significativos.
A Figura 4.24 mostra a adição do componente para jump à Figura 4.17. Um outro multiplexador é usado na seleção da origem para o novo valor do PC,
que pode ser o PC incrementado (PC + 4), o PC de destino de um branch ou o PC de destino de um jump. Um sinal de controle adicional é necessário para o multiplexador adicional. Esse sinal de controle, chamado Jump, é ativado apenas quando a instrução é um jump — ou seja, quando o opcode é 2.
FIGURA 4.24 O controle e o caminho de dados simples são estendidos para lidar com a instrução jump. Um multiplexador adicional (no canto superior direito) é usado na escolha entre o destino de um jump e o destino de um desvio ou a instrução sequencial seguinte a esta. Esse multiplexador é controlado pelo sinal de controle Jump. O endereço de destino do jump é obtido deslocando-se os 26 bits inferiores da instrução jump de 2 bits para a esquerda, efetivamente adicionando 00 como os bits menos significativos, e, depois, concatenando os 4 bits mais significativos do PC + 4 como os bits mais significativos, produzindo, assim, um endereço de 32 bits.
Por que uma implementação de ciclo único não é usada hoje Embora o projeto de ciclo único funcionasse corretamente, ele não seria usado nos projetos modernos porque é ineficiente. Para ver o porquê disso, observe que o ciclo de clock precisa ter a mesma duração para cada instrução nesse projeto de ciclo único. É claro, o ciclo de clock é determinado pelo caminho mais longo possível no processador. Esse caminho é, quase certamente, uma instrução load, que usa cinco unidades funcionais em série: a memória de instruções, o banco de registradores, a ALU, a memória de dados e o banco de registradores. Embora o CPI seja 1 (Capítulo 1), o desempenho geral de uma implementação de ciclo único provavelmente será fraco, já que o ciclo de clock é muito longo. O ônus de usar o projeto de ciclo único com um ciclo de clock fixo é significativo, mas poderia ser considerado aceitável para este pequeno conjunto de instruções. Historicamente, os primeiros computadores com conjuntos de instruções muito simples usavam essa tecnologia de implementação. Entretanto, se tentássemos implementar a unidade de ponto flutuante ou um conjunto de instruções com orientações mais complexas, esse projeto de ciclo único decididamente não funcionaria bem. Como precisamos considerar que o ciclo de clock é igual ao atraso do pior caso para todas as instruções, não podemos usar técnicas de implementação que reduzem o atraso do caso comum, mas não melhoram o tempo de ciclo de pior caso. Uma implementação de ciclo único, portanto, viola o nosso princípio básico de projeto do Capítulo 2 de tornar o caso comum veloz.
Na próxima seção, veremos outra técnica de implementação, chamada pipelining, que usa um caminho de dados muito semelhante ao caminho de dados de ciclo único, mas é muito mais eficiente por ter uma vazão muito mais alta. O pipelining melhora a eficiência executando múltiplas instruções simultaneamente.
Verifique você mesmo Veja os sinais de controle na Figura 4.22. Você consegue combinar alguns deles? Algum sinal de controle na figura pode ser substituído pelo inverso de outro? (Dica: leve em conta os don’t care.) Nesse caso, você pode trocar um sinal pelo outro sem incluir um inversor?
4.5. Visão geral de pipelining Nunca perca tempo. Provérbio americano
Pipelining é uma técnica de implementação em que várias instruções são sobrepostas na execução. Hoje, a técnica de pipelining é praticamente universal.
pipelining Uma técnica de implementação em que várias instruções são sobrepostas na execução, semelhante a uma linha de montagem. Esta seção utiliza bastante uma analogia para dar uma visão geral dos termos e aspectos da técnica de pipelining. Se você estiver interessado apenas no quadro geral, deverá se concentrar nesta seção e depois pular para as Seções 4.10 e 4.11, a fim de ver uma introdução às técnicas de pipelining avançadas, utilizadas nos processadores mais recentes, como o Intel Core i7 e o ARM Cortex-A8. Se estiver interessado em explorar a anatomia de um computador com pipeline, esta seção é uma boa introdução às Seções de 4.6 a 4.9. Qualquer um que tenha lavado muitas roupas intuitivamente já usou pipelining. A técnica sem pipeline para lavar roupas seria: 1. Colocar a trouxa de roupa suja na lavadora. 2. Quando a lavadora terminar, colocar a trouxa de roupa molhada na
secadora. 3. Quando a secadora terminar, colocar a trouxa de roupa seca na mesa e passar. 4. Quando terminar de passar, pedir ao seu colega de quarto para guardar as roupas. Quando seu colega terminar, então comece novamente com a próxima trouxa de roupa suja.
A técnica com pipeline leva muito menos tempo, como mostra a Figura 4.25. Assim que a lavadora terminar com a primeira trouxa e ela for colocada na secadora, você carrega a lavadora com a segunda trouxa de roupa suja. Quando a primeira trouxa estiver seca, você a coloca na tábua para começar a passar e dobrar, move a trouxa de roupa molhada para a secadora e a próxima trouxa de roupa suja para a lavadora. Em seguida, você pede a seu colega para guardar a primeira remessa, começa a passar e dobrar a segunda, a secadora está com a terceira remessa e você coloca a quarta na lavadora. Nesse ponto, todas as etapas — denominadas estágios em pipelining — estão operando simultaneamente. Desde que haja recursos separados para cada estágio, podemos usar um pipeline
para as tarefas.
FIGURA 4.25 A analogia da lavagem de roupas para pipelining. Ana, Beto, Catarina e Davi possuem roupas sujas para serem lavadas, secadas, passadas e guardadas. O lavador, o secador, o passador e o guardador levam 30 minutos para sua tarefa. A lavagem sequencial levaria oito horas para quatro trouxas de roupas, enquanto a lavagem com pipeline levaria apenas 3,5 horas. Mostramos o estágio do pipeline de diferentes trouxas com o passar do tempo, mostrando cópias dos quatro recursos nessa linha de tempo bidimensional, mas na realidade temos apenas um de cada recurso.
O paradoxo da técnica de pipelining é que o tempo desde a colocação de uma única trouxa de roupa suja na lavadora até que ela esteja seca, e seja passada e guardada não é mais curto para a técnica de pipelining; o motivo pelo qual a
técnica de pipelining é mais rápida para muitas trouxas é que tudo está trabalhando em paralelo, de modo que mais trouxas são terminadas por hora. A técnica de pipelining melhora a vazão do sistema de lavanderia. Logo, a técnica de pipelining não diminuiria o tempo para concluir uma trouxa de roupas, mas, quando temos muitas trouxas para lavar, a melhoria na vazão diminui o tempo total de conclusão do trabalho. Se todos os estágios levarem aproximadamente o mesmo tempo e houver trabalho suficiente para realizar, então o ganho de velocidade devido à técnica de pipelining será igual ao número de estágios do pipeline, neste caso, quatro: lavar, secar, passar e guardar. Assim, a lavanderia com pipeline é potencialmente quatro vezes mais rápida do que a sem pipeline: 20 trouxas levariam cerca de cinco vezes o tempo de uma trouxa, enquanto 20 trouxas de lavagem sequencial levariam 20 vezes o tempo de uma trouxa. O ganho foi de apenas 2,3 vezes na Figura 4.25 porque mostramos apenas quatro trouxas. Observe que, no início e no final da carga de trabalho na versão com pipeline da Figura 4.25, o pipeline não está completamente cheio. Esse efeito no início e no fim afeta o desempenho quando o número de tarefas não é grande em comparação com a quantidade de estágios do pipeline. Se o número de trouxas for muito maior que 4, então os estágios estarão cheios na maior parte do tempo e o aumento na vazão será muito próximo de 4. Os mesmos princípios se aplicam a processadores em que usamos pipeline para a execução da instrução. As instruções MIPS normalmente exigem cinco etapas: 1. Buscar instrução da memória. 2. Ler registradores enquanto a instrução é decodificada. O formato das instruções MIPS permite que a leitura e a decodificação ocorram simultaneamente. 3. Executar a operação ou calcular um endereço. 4. Acessar um operando na memória de dados. 5. Escrever o resultado em um registrador. Logo, o pipeline MIPS que exploramos neste capítulo possui cinco estágios. O exemplo a seguir mostra que a técnica de pipelining agiliza a execução da instrução, assim como agiliza a lavagem de roupas.
Desempenho de ciclo único versus desempenho com pipeline Exemplo
Exemplo Para tornar esta discussão concreta, vamos criar um pipeline. Neste exemplo, e no restante deste capítulo, vamos limitar nossa atenção a oito instruções: load word (lw), store word (sw), add (add), subtract (sub), AND (and), OR (or), set-less-than (slt) e branch-on-equal (beq). Compare o tempo médio entre as instruções de uma implementação em ciclo único, em que todas as instruções levam um ciclo de clock, com uma implementação com pipeline. Os tempos de operação para as principais unidades funcionais neste exemplo são de 200 ps para acesso à memória, 200 ps para operação com ALU e 100 ps para leitura ou escrita de registradores. No modelo de ciclo único, cada instrução leva exatamente um ciclo de clock, de modo que o ciclo precisa ser esticado para acomodar a instrução mais lenta.
Resposta A Figura 4.26 mostra o tempo exigido para cada uma das oito instruções. O projeto de ciclo único precisa contemplar a instrução mais lenta — na Figura 4.26, ela é lw — de modo que o tempo exigido para cada instrução é 800 ps. Assim como na Figura 4.25, a Figura 4.27 compara a execução sem pipeline e com pipeline de três instruções load word. Desse modo, o tempo entre a primeira e a quarta instrução no projeto sem pipeline é 3 × 800 ps ou 2.400 ps.
FIGURA 4.26 Tempo total para cada instrução calculada a partir do tempo para cada componente. Esse cálculo considera que os multiplexadores, unidade de controle, acessos ao PC e unidade de extensão de sinal não possuem atraso.
FIGURA 4.27 Em cima, execução em ciclo único, sem pipeline, versus execução com pipeline, embaixo. Ambas utilizam os mesmos componentes de hardware, cujo tempo está listado na Figura 4.26. Neste caso, vemos um ganho de velocidade de quatro vezes no tempo médio entre as instruções, de 800 ps para 200 ps. Compare com a Figura 4.25. Para a lavanderia, consideramos que todos os estágios eram iguais. Se a secadora fosse mais lenta, então o estágio da secadora definiria o tempo do estágio. Os tempos de estágio do pipeline dos computadores são limitados pelo recurso mais lento, seja a operação da ALU ou o acesso à memória. Consideramos que a escrita no banco de registradores ocorre na primeira metade do ciclo de clock e a leitura do banco de registradores ocorre na segunda metade. Usamos essa suposição por todo este capítulo.
Todos os estágios do pipeline utilizam um único ciclo de clock, de modo que ele precisa ser grande o suficiente para acomodar a operação mais lenta. Assim como o projeto de ciclo único de clock precisa levar o tempo do ciclo de clock no pior caso, de 800 ps, embora algumas instruções possam ser tão rápidas quanto 500 ps, o ciclo de clock da execução com pipeline precisa ter o ciclo de clock, no pior caso, de 200 ps, embora alguns estágios levem apenas
100 ps. O uso de pipeline ainda oferece uma melhoria de desempenho de quatro vezes: o tempo entre a primeira e a quarta instruções é de 3 × 200 ps ou 600 ps. Podemos converter a discussão sobre ganho de velocidade com a técnica de pipelining em uma fórmula. Se os estágios forem perfeitamente balanceados, então o tempo entre as instruções no processador com pipeline — assumindo condições ideais — é igual a:
Sob condições ideais e com uma grande quantidade de instruções, o ganho de velocidade com a técnica de pipelining é aproximadamente igual ao número de estágios do pipeline; um pipeline de cinco estágios é quase cinco vezes mais rápido. A fórmula sugere que um pipeline de cinco estágios deve oferecer uma melhoria de quase cinco vezes sobre o tempo sem pipeline de 800 ps ou um ciclo de clock de 160 ps. Entretanto, o exemplo mostra que os estágios podem ser mal balanceados. Além disso, a técnica de pipelining envolve algum overhead, cuja origem se tornará mais clara adiante. Assim, o tempo por instrução no processador com pipeline será superior ao mínimo possível, e o ganho de velocidade será menor que o número de estágios do pipeline. Além do mais, até mesmo nossa afirmação de uma melhoria de quatro vezes para nosso exemplo não está refletida no tempo de execução total para as três instruções: são 1.400 ps versus 2.400 ps. Naturalmente, isso acontece porque o número de instruções não é grande. O que aconteceria se aumentássemos o número de instruções? Poderíamos estender os valores anteriores para 1.000.003 instruções. Acrescentaríamos 1.000.000 instruções no exemplo com pipeline; cada instrução acrescenta 200 ps ao tempo de execução total. O tempo de execução total seria 1.000.000 × 200 ps + 1.400 ps, ou 200.001.400 ps. No exemplo sem pipeline, acrescentaríamos 1.000.000 instruções, cada uma exigindo 800 ps de modo que o tempo de execução total seria 1.000.000 × 800 ps + 2.400 ps ou 800.002.400 ps. Sob essas condições ideais, a razão entre os tempos de execução total para os programas reais nos processadores sem
pipeline e com pipeline é próximo da razão de tempos entre as instruções:
A técnica de pipelining melhora o desempenho aumentando a vazão de instruções, em vez de diminuir o tempo de execução de uma instrução individual, mas a vazão de instruções é a medida importante, pois os programas reais executam bilhões de instruções.
Projetando conjuntos de instruções para pipelining Mesmo com essa explicação simples sobre pipelining, podemos entender melhor o projeto do conjunto de instruções MIPS, projetado para execução com pipeline. Primeiro, todas as instruções MIPS têm o mesmo tamanho. Essa restrição torna muito mais fácil buscar instruções no primeiro estágio do pipeline e decodificá-las no segundo estágio. Em um conjunto de instruções como o x86, no qual as instruções variam de 1 byte a 15 bytes, a técnica de pipelining é muito mais desafiadora. As implementações recentes da arquitetura x86 na realidade traduzem instruções x86 em micro-operações simples, que se parecem com instruções MIPS e depois usam um pipeline de micro-operações no lugar das instruções x86 nativas! (Seção 4.10.) Em segundo lugar, o MIPS tem apenas alguns poucos formatos de instrução, com os campos do registrador de origem localizados no mesmo lugar em cada instrução. Essa simetria significa que o segundo estágio pode começar a ler o banco de registradores ao mesmo tempo em que o hardware está determinando que tipo de instrução foi lida. Se os formatos de instrução do MIPS não fossem simétricos, precisaríamos dividir o estágio 2, resultando em seis estágios de pipeline. Logo veremos a desvantagem dos pipelines mais longos. Em terceiro lugar, os operandos em memória só aparecem em loads ou stores no MIPS. Essa restrição significa que podemos usar o estágio de execução para calcular o endereço de memória e depois acessar a memória no estágio seguinte. Se pudéssemos operar sobre os operandos na memória, como na arquitetura x86, os estágios 3 e 4 se expandiriam para estágio de endereço, estágio de memória e,
em seguida, estágio de execução. Em quarto lugar, conforme discutimos no Capítulo 2, os operandos precisam estar alinhados na memória. Logo, não precisamos nos preocupar com uma única instrução de transferência de dados exigindo dois acessos à memória de dados; os dados solicitados podem ser transferidos entre o processador e a memória em um único estágio do pipeline.
Hazards de pipeline Existem situações em pipelining em que a próxima instrução não pode ser executada no ciclo de clock seguinte. Esses eventos são chamados hazards e existem três tipos diferentes. Hazards estruturais O primeiro hazard é chamado hazard estrutural. Ele significa que o hardware não pode admitir a combinação de instruções que queremos executar no mesmo ciclo de clock. Um hazard estrutural na lavanderia aconteceria se usássemos uma combinação lavadora-secadora no lugar de lavadora e secadora separadas ou se nosso colega estivesse ocupado com outra coisa e não pudesse guardar as roupas. Nosso pipeline, cuidadosamente programado, fracassaria.
hazard estrutural Uma ocorrência em que uma instrução planejada não pode ser executada no ciclo de clock apropriado, pois o hardware não admite a combinação de instruções definidas para executar em determinado ciclo de clock. Como dissemos, o conjunto de instruções MIPS foi projetado para ser executado em um pipeline, tornando muito fácil para os projetistas evitar hazards estruturais quando projetaram o pipeline. Contudo, suponha que tivéssemos uma única memória, em vez de duas. Se o pipeline da Figura 4.27 tivesse uma quarta instrução, veríamos que, no mesmo ciclo de clock em que a primeira instrução está acessando dados da memória, a quarta instrução está buscando uma instrução dessa mesma memória. Sem duas memórias, nosso pipeline poderia ter um hazard estrutural. Hazards de dados Os hazards de dados ocorrem quando o pipeline precisa ser interrompido
porque uma etapa precisa esperar até que outra seja concluída. Suponha que você tenha encontrado uma meia na mesa de passar para a qual não exista um par. Uma estratégia possível é correr até o seu quarto e procurar em sua gaveta para ver se consegue encontrar o par. Obviamente, enquanto você está procurando, as roupas que ficaram secas e estão prontas para serem passadas, e aquelas que acabaram de ser lavadas e estão prontas para secarem deverão esperar.
hazard de dados Também chamado hazard de dados do pipeline. Uma ocorrência em que uma instrução planejada não pode ser executada no ciclo de clock correto porque os dados necessários para executar a instrução ainda não estão disponíveis. Em um pipeline de computador, os hazards de dados surgem quando uma instrução depende de uma anterior, que ainda está no pipeline (um relacionamento que não existe realmente quando se lavam roupas). Por exemplo, suponha que tenhamos uma instrução add seguida imediatamente por uma instrução subtract que usa a soma ($s0):
Sem intervenção, um hazard de dados poderia prejudicar o pipeline severamente. A instrução add não escreve seu resultado até o quinto estágio, significando que teríamos de desperdiçar três ciclos de clock no pipeline. Embora pudéssemos contar com compiladores para remover todos esses hazards, os resultados não seriam satisfatórios. Essas dependências acontecem com muita frequência, e o atraso simplesmente é muito longo para se esperar que o compilador nos tire desse dilema. A solução principal é baseada na observação de que não precisamos esperar que a instrução termine antes de tentar resolver o hazard de dados. Para a sequência de código anterior, assim que a ALU cria a soma para o add, podemos
fornecê-la como uma entrada para a subtração. O acréscimo de hardware extra para ter o item que falta antes do previsto, diretamente dos recursos internos, é chamado de forwarding ou bypassing.
forwarding Também chamado bypassing. Um método para resolver um hazard de dados utilizando o elemento de dados que falta a partir de buffers internos, em vez de esperar que chegue nos registradores visíveis ao programador ou na memória.
Forwarding com duas instruções Exemplo Para as duas instruções anteriores, mostre quais estágios do pipeline estariam conectados pelo forwarding. Use o desenho da Figura 4.28 para representar o caminho de dados durante os cinco estágios do pipeline. Alinhe a cópia do caminho de dados para cada instrução, semelhante ao pipeline da lavanderia, na Figura 4.25.
FIGURA 4.28 Representação gráfica do pipeline de instrução, semelhante em essência ao pipeline da lavanderia, na Figura 4.25. Aqui, usamos símbolos representando os recursos físicos com as abreviações para estágios de pipeline usados no decorrer do capítulo. Os símbolos para os cinco estágios: IF para o estágio de busca de instrução, com a caixa representando a memória de instrução; ID para o estágio de leitura de decodificação de instrução/banco de registradores, com o desenho mostrando o banco de registradores sendo lido; EX para o estágio de execução, com o desenho representando a ALU; MEM para o estágio de acesso à memória, com a caixa representando a memória de dados; e WB para o estágio
write-back, com o desenho mostrando o banco de registradores sendo escrito. O sombreamento indica que o elemento é usado pela instrução. Logo, MEM tem um fundo branco porque add não acessa a memória de dados. O sombreamento na metade direita do banco de registradores ou memória significa que o elemento é lido nesse estágio, e o sombreamento da metade esquerda significa que ele é escrito nesse estágio. Logo, a metade direita do ID é sombreada no segundo estágio porque o banco de registradores é lido, e a metade esquerda do WB é sombreada no quinto estágio, pois o banco de registradores é escrito.
Resposta A Figura 4.29 mostra a conexão para o forwarding do valor em $s0 após o estágio de execução da instrução add como entrada para o estágio de execução da instrução sub.
FIGURA 4.29 Representação gráfica do forwarding. A conexão mostra o caminho do forwarding desde a saída do estágio EX de add até a entrada do estágio EX para sub, substituindo o valor do registrador $s0 lido no segundo estágio de sub.
Nessa representação gráfica dos eventos, os caminhos de forwarding só são válidos se o estágio de destino estiver mais adiante no tempo do que o estágio de origem. Por exemplo, não pode haver um caminho de forwarding válido da saída do estágio de acesso à memória na primeira instrução para a entrada do estágio
de execução da instrução seguinte, pois isso significaria voltar no tempo. O forwarding funciona muito bem e é descrito com detalhes na Seção 4.7. Entretanto, ele não pode impedir todos os stalls do pipeline. Por exemplo, suponha que a primeira instrução fosse um load de $s0 em vez de um add. Como podemos imaginar examinando a Figura 4.29, os dados desejados só estariam disponíveis depois do quarto estágio da primeira instrução na dependência, que é muito tarde para a entrada do terceiro estágio de sub. Logo, até mesmo com o forwarding, teríamos de atrasar um estágio para um hazard de dados no uso de load, como mostra a Figura 4.30. Essa figura mostra um conceito importante de pipeline, conhecido oficialmente como pipeline stall, mas normalmente recebendo o apelido de bolha. Veremos os stalls em outros lugares do pipeline. A Seção 4.7 mostra como podemos tratar de casos assim, usando a detecção de hardware e stalls ou software que reordena o código para evitar stalls de pipeline no uso de load, como este exemplo ilustra.
FIGURA 4.30 Precisamos de um stall até mesmo com forwarding, quando uma instrução do formato R após um load tenta usar os dados. Sem o stall, o caminho da saída do estágio de acesso à memória para a entrada do estágio de execução estaria ao contrário no tempo, o que é impossível. Essa figura, na realidade, é uma simplificação, pois não podemos saber, antes que a instrução de subtração seja lida e decodificada, se um stall será necessário. A Seção 4.7 mostra os detalhes do que realmente acontece no caso de um hazard.
hazard de dados no uso de load Uma forma específica de hazard de dados em que os dados solicitados por uma instrução load ainda não estão disponíveis quando necessários para outra instrução.
pipeline stall Também chamado bolha. Um stall iniciado a fim de resolver um hazard.
Reordenando o código para evitar pipeline stalls Exemplo Considere o seguinte segmento de código em C:
Aqui está o código MIPS gerado para esse segmento, supondo que todas as variáveis estejam na memória e sejam endereçáveis como offsets, a partir de $t0:
Encontre os hazards no segmento de código a seguir e reordene as instruções para evitar quaisquer pipeline stalls.
Resposta As duas instruções add possuem um hazard, devido à respectiva dependência da instrução lw imediatamente anterior. Observe que o bypassing elimina vários outros hazards em potencial, incluindo a dependência do primeiro add no primeiro lw e quaisquer hazards para instruções store. Subir a terceira instrução lw elimina os dois hazards:
Em um processador com pipeline com forwarding, a sequência reordenada será completada em dois ciclos a menos do que a versão original. O forwarding leva a outro detalhe da arquitetura MIPS, além dos quatro mencionados anteriormente. Cada instrução MIPS escreve no máximo um resultado e faz isso no último estágio do pipeline. O forwarding é mais difícil se houver vários resultados para encaminhar por instrução, ou se for preciso, escrever um resultado mais cedo na execução da instrução.
Detalhamento o nome “forwarding” vem da ideia de que o resultado é passado adiante (em inglês forwarded) a partir de uma instrução anterior para uma instrução posterior. “Bypassing” vem de contornar (do inglês bypass) o resultado pelo banco de registradores até chegar à unidade desejada. Hazards de controle O terceiro tipo de hazard é chamado hazard de controle, vindo da necessidade de tomar uma decisão com base nos resultados de uma instrução enquanto outras estão sendo executadas.
hazard de controle Também chamado hazard de desvio Quando a instrução apropriada não pode ser executada no devido ciclo de clock de pipeline porque a instrução buscada não é aquela necessária; ou seja, o fluxo de endereços de instrução não é o que o pipeline esperava. Suponha que nosso pessoal da lavanderia receba a tarefa feliz de limpar os uniformes de um time de futebol. Como a roupa é muito suja, temos de determinar se o detergente e a temperatura da água que selecionamos são fortes o suficiente para limpar os uniformes, mas não tão forte para desgastá-los antes do tempo. Em nosso pipeline de lavanderia, temos de esperar até o segundo estágio e examinar o uniforme seco para ver se precisamos ou não mudar as opções da lavadora. O que fazer? Aqui está a primeira das duas soluções para controlar os hazards na lavanderia e seu equivalente nos computadores. Stall: Basta operar sequencialmente até que o primeiro lote esteja seco e depois repetir até você ter a fórmula correta. Essa opção conservadora certamente funciona, mas é lenta. A tarefa de decisão equivalente em um computador é a instrução de desvio. Observe que temos de começar a buscar a instrução após o desvio no próximo ciclo de clock. Contudo, o pipeline possivelmente não saberá qual deve ser a próxima instrução, pois ele acabou de receber da memória a instrução de desvio! Assim como na lavanderia, uma solução possível é ocasionar um stall no pipeline imediatamente após buscarmos um desvio, esperando até que o pipeline determine o resultado do desvio para saber em qual endereço apanhar a próxima instrução. Vamos supor que colocamos hardware extra suficiente para que possamos testar registradores, calcular o endereço de desvio e atualizar o PC durante o segundo estágio do pipeline (Seção 4.8). Até mesmo com esse hardware extra, o pipeline envolvendo desvios condicionais se pareceria com a Figura 4.31. A instrução lw, executada se o desvio não for tomado, fica em stall durante um ciclo de clock extra de 200 ps antes de iniciar
FIGURA 4.31 Pipeline mostrando o stall em cada desvio condicional como solução para controlar os hazards. Este exemplo considera que o desvio condicional é tomado e a instrução no destino do desvio é a instrução OR. Existe um stall de um estágio no pipeline ou bolha, após o desvio. Na realidade, o processo de criação de um stall é ligeiramente mais complicado, conforme veremos na Seção 4.8. No entanto, o efeito sobre o desempenho é o mesmo que ocorreria se uma bolha fosse inserida.
Desempenho do “stall no desvio” Exemplo Estime o impacto nos ciclos de clock por instrução (CPI) do stall nos desvios. Suponha que todas as outras instruções tenham um CPI de 1.
Resposta A Figura 3.27 no Capítulo 3 mostra que os desvios são 17% das instruções executadas no SPECint2006. Como as outras instruções possuem um CPI de 1 e os desvios tomaram um ciclo de clock extra para o stall, então veríamos um CPI de 1,17 e, portanto, um stall de 1,17 em relação ao caso ideal. Se não pudermos resolver o desvio no segundo estágio, como normalmente acontece para pipelines maiores, então veríamos um atraso ainda maior se ocorresse um stall nos desvios. O custo dessa opção é muito alto para a maioria dos computadores utilizar, e isso motiva uma segunda solução para o hazard de controle, usando uma de nossas grandes ideias do Capítulo 1:
Prever: se você estiver certo de que tem a fórmula correta para lavar os uniformes, então basta prever que ela funcionará e lavar a segunda remessa enquanto espera que a primeira seque. Essa opção não atrasa o pipeline quando você estiver correto. Entretanto, quando estiver errado, você terá de refazer a remessa que foi lavada enquanto pensa na decisão. Os computadores realmente utilizam a predição para tratar dos desvios. Uma técnica simples é sempre prever que os desvios não serão tomados. Quando você estiver certo, o pipeline prosseguirá a toda velocidade. Somente quando os desvios são tomados é que o pipeline sofre um stall. A Figura 4.32 mostra um exemplo assim.
FIGURA 4.32 Prevendo que os desvios não serão tomados como solução para o hazard de controle.
O desenho superior mostra o pipeline quando o desvio não é tomado. O desenho inferior mostra o pipeline quando o desvio é tomado. Conforme observamos na Figura 4.31, a inserção de uma bolha nesse padrão simplifica o que realmente acontece, pelo menos durante o primeiro ciclo de clock, imediatamente após o desvio. A Seção 4.8 esclarecerá os detalhes.
Uma versão mais sofisticada de previsão de desvio teria alguns desvios previstos como tomados e alguns como não tomados. Em nossa analogia, os uniformes escuros ou de casa poderiam usar uma fórmula, enquanto os uniformes claros ou de sair poderiam usar outra. No caso da programação, no final dos loops existem desvios que voltam para o início do loop. Como provavelmente serão tomados e desviam para trás, sempre poderíamos prever como tomados os desvios para um endereço anterior.
previsão de desvio
Um método de resolver um hazard de desvio que considera um determinado resultado para o desvio e prossegue a partir dessa suposição, em vez de esperar para verificar o resultado real.
Essas técnicas rígidas para o desvio contam com o comportamento estereotipado e não levam em consideração a individualidade de uma instrução de desvio específica. Previsores de hardware dinâmicos, ao contrário, fazem suas escolhas dependendo do comportamento de cada desvio, e podem mudar as previsões para um desvio durante a vida de um programa. Seguindo nossa analogia, na previsão dinâmica, uma pessoa veria como o uniforme estava sujo e escolheria a fórmula, ajustando a próxima escolha dependendo do sucesso das escolhas recentes. Uma técnica comum para a previsão dinâmica de desvios é manter um histórico de cada desvio como tomado ou não tomado, e depois usar o comportamento passado recente para prever o futuro. Como veremos mais adiante, a quantidade e o tipo de histórico mantido têm se tornado extensos,
resultando em previsores de desvio dinâmicos que podem prever os desvios corretamente, com uma precisão superior a 90% (Seção 4.8). Quando a escolha estiver errada, o controle do pipeline terá de garantir que as instruções após o desvio errado não tenham efeito, reiniciando o pipeline a partir do endereço de desvio apropriado. Em nossa analogia de lavanderia, temos de deixar de aceitar novas remessas para poder reiniciar a remessa prevista incorretamente. Como no caso de todas as outras soluções para controlar hazards, pipelines mais longos aumentam o problema, neste caso, aumentando o custo do erro de previsão. As soluções para controlar os hazards são descritas com mais detalhes na Seção 4.8.
Detalhamento Existe uma terceira técnica para o hazard de controle, chamada decisão adiada. Em nossa analogia, sempre que você tiver de tomar uma decisão sobre a lavanderia, basta colocar uma remessa de roupas que não sejam de futebol na lavadora, enquanto espera que os uniformes de futebol sequem. Desde que você tenha roupas sujas suficientes, que não sejam afetadas pelo teste, essa solução funcionará bem. Chamado de delayed branch (desvio adiado) nos computadores, essa é a solução realmente usada pela arquitetura MIPS. O delayed branch sempre executa a próxima instrução sequencial, com o desvio ocorrendo após esse atraso de uma instrução. Isso fica escondido do programador assembly do MIPS, pois o montador pode arrumar as instruções automaticamente para conseguir o comportamento de desvio desejado pelo programador. O software MIPS colocará uma instrução imediatamente após a instrução de delayed branch, que não é afetada pelo desvio, e um desvio tomado muda o endereço da instrução que vem após essa instrução segura. Em nosso exemplo, a instrução add antes do desvio na Figura 4.31 não o afeta, e pode ser movida para depois dele, a fim de esconder totalmente seu atraso. Como os delayed branches são úteis quando os desvios são curtos, nenhum processador usa um delayed branch de mais de um ciclo. Para atrasos em desvios maiores, a previsão de desvio baseada em hardware normalmente é usada.
Resumo da visão geral de pipelining Pipelining é uma técnica que explora o paralelismo entre as instruções em um
fluxo de instruções sequenciais. Ela tem a vantagem substancial de que, diferente de programar um multiprocessador, ela é fundamentalmente invisível ao programador.
Nas próximas seções deste capítulo, abordamos o conceito de pipelining usando o subconjunto de instruções MIPS da implementação de ciclo único na Seção 4.4 e mostramos uma versão simplificada de seu pipeline. Depois, examinamos os problemas que a técnica de pipelining gera e o desempenho alcançável em situações típicas.
Se você quiser saber mais sobre o software e as implicações de desempenho da técnica de pipelining, agora terá base suficiente para pular para a Seção 4.10. A Seção 4.10 apresenta conceitos avançados de pipelining, como o escalonamento superescalar e dinâmico, e a Seção 4.11 examina os pipelines de microprocessadores recentes. Como alternativa, se você estiver interessado em entender como a técnica de pipelining é implementada e os desafios de lidar com hazards, poderá prosseguir para examinar o projeto de um caminho de dados com pipeline, explicado na Seção 4.6. Depois, você poderá usar esse conhecimento para explorar a implementação do forwarding e stalls na Seção 4.7. Você poderá, então, ler a Seção 4.8 e aprender mais sobre soluções para hazards de desvio, e depois ver como as exceções são tratadas, na Seção 4.9.
Verifique você mesmo Para cada sequência de código a seguir, indique se ela deverá sofrer stall, pode evitar stalls usando apenas forwarding ou pode ser executada sem stall ou forwarding: Sequência 1
Sequência 2
Sequência 3
Entendendo o desempenho dos programas Fora do sistema de memória, a operação eficaz do pipeline normalmente é o fator mais importante para determinar o CPI do processador e, portanto, seu desempenho. Conforme veremos na Seção 4.10, compreender o desempenho de um processador moderno com múltiplos problemas é algo complexo e exige a compreensão de mais do que apenas as questões que surgem em um processador com pipeline simples. Apesar disso, os hazards estruturais, de dados e de controle continuam sendo importantes em pipelines simples e mais sofisticados. Para pipelines modernos, os hazards estruturais costumam girar em torno da unidade de ponto flutuante, que pode não ser totalmente implementada com pipeline, enquanto os hazards de controle costumam ser um problema maior nos programas de inteiros, que costumam ter maiores frequências de desvio, além de desvios menos previsíveis. Os hazards de dados podem ser gargalos de desempenho em programas de inteiros e de ponto flutuante. Em geral, é mais fácil lidar com hazards de dados em programas de ponto flutuante porque a menor frequência de desvios e os padrões de acesso mais regulares permitem que o compilador tente escalonar instruções para evitar os hazards. É mais difícil realizar essas otimizações em programas de inteiros, que possuem acesso menos regular e envolvem um maior uso de ponteiros. Conforme veremos na Seção 4.10, existem técnicas de compilação e de hardware mais ambiciosas que reduzem as dependências de dados para o escalonamento.
Colocando em perspectiva A técnica de pipelining aumenta o número de instruções em execução simultânea e a velocidade em que as instruções são iniciadas e concluídas. A técnica de pipelining não reduz o tempo gasto para completar uma instrução individual, também chamado de latência. Por exemplo, o pipeline de cinco estágios ainda usa cinco ciclos de clock para completar a instrução. Nos termos usados no Capítulo 1, a técnica de pipelining melhora a vazão de instruções, e não o tempo de execução ou latência das instruções individualmente.
latência (pipeline) O número de estágios em um pipeline ou o número de estágios entre duas instruções durante a execução. Os conjuntos de instruções podem simplificar ou dificultar a vida dos projetistas do pipeline, que já precisam enfrentar hazards estruturais, de controle e de dados. A previsão de desvio, o forwarding e os stalls ajudam a tornar um
computador rápido enquanto ainda gera as respostas certas.
4.6. Caminho de dados e controle usando pipeline Há menos coisa nisso do que os olhos podem ver. Tallulah Bankhead, comentário para Alexander Woollcott, 1922
A Figura 4.33 mostra o caminho de dados de ciclo único da Seção 4.4 com os estágios de pipeline identificados. A divisão de uma instrução em cinco estágios significa um pipeline de cinco estágios que, por sua vez, significa que até cinco instruções estarão em execução durante qualquer ciclo de clock. Assim, temos de separar o caminho de dados em cinco partes, com cada parte possuindo um nome correspondente a um estágio da execução da instrução: 1. IF (Instruction Fetch): Busca de instruções.
FIGURA 4.33 O caminho de dados da Seção 4.4 (semelhante à Figura 4.17). Cada etapa da instrução pode ser mapeada no caminho de dados da esquerda para a direita. As únicas exceções são a atualização do PC e a etapa de escrita do resultado, mostrada em destaque, que envia o resultado da ALU ou os dados da memória para a esquerda, a fim de serem escritos no banco de registradores. (Normalmente, usamos linhas coloridas para controle, mas são linhas de dados.)
2. ID (Instruction Decode): Decodificação de instruções e leitura do banco de registradores. 3. EX: Execução ou cálculo de endereço. 4. MEM: Acesso à memória de dados. 5. WB (Write Back): Escrita do resultado. Na Figura 4.33, esses cinco componentes correspondem aproximadamente ao modo como o caminho de dados é desenhado; as instruções e os dados em geral se movem da esquerda para a direita pelos cinco estágios enquanto completam a execução. Voltando à nossa analogia da lavanderia, as roupas ficam mais limpas, mais secas e mais organizadas à medida que prosseguem na fila, e nunca se
movem para trás. Entretanto, existem duas exceções para esse fluxo de informações da esquerda para a direita: ▪ O estágio de escrita do resultado, que coloca o resultado de volta no banco de registradores, no meio do caminho de dados; ▪ A seleção do próximo valor do PC, escolhendo entre o PC incrementado e o endereço de desvio do estágio MEM. Os dados fluindo da direita para a esquerda não afetam a instrução atual; somente as instruções seguintes no pipeline são influenciadas por esses movimentos de dados reversos. Observe que a primeira seta da direita para a esquerda pode levar a hazards de dados e a segunda ocasiona hazards de controle. Uma maneira de mostrar o que acontece na execução com pipeline é fingir que cada instrução tem seu próprio caminho de dados e depois colocar esses caminhos de dados em uma linha de tempo para mostrar seu relacionamento. A Figura 4.34 mostra a execução das instruções na Figura 4.27, exibindo seus caminhos de dados privados em uma linha de tempo comum. Usamos uma versão estilizada do caminho de dados na Figura 4.33 para mostrar os relacionamentos na Figura 4.34.
FIGURA 4.34 Instruções executadas usando o caminho de dados de ciclo único na Figura 4.33, assumindo a execução com pipeline. Semelhante às Figuras de 4.28 a 4.30, esta figura finge que cada instrução possui seu próprio caminho de dados e pinta cada parte de acordo com o uso. Ao contrário daquelas figuras, cada estágio é rotulado pelo recurso físico usado nesse estágio, correspondendo às partes do caminho de dados na Figura 4.33. IM representa a memória de instruções e o PC no estágio de busca da instrução, Reg significa banco de registradores e extensor de sinal no estágio de decodificação de instruções/leitura do banco de registradores (ID), e assim por diante. Para manter a ordem de tempo correta, esse caminho de dados estilizado divide o banco de registradores em duas partes lógicas: leitura de registradores durante a busca de registradores (ID) e registradores escritos durante a escrita do resultado (WB). Esse uso dual é representado pelo desenho da metade esquerda não sombreada do banco de registradores, usando linhas tracejadas no estágio ID, quando ele não estiver sendo escrito e a metade direita não sombreada usando linhas tracejadas do estágio WB, quando não estiver sendo lido. Como antes, consideramos que o banco de registradores é escrito na primeira metade do ciclo de clock e é lido durante a segunda metade.
A Figura 4.34 parece sugerir que três instruções precisam de três caminhos de dados. Em vez disso, acrescentamos registradores para manter dados de modo que partes do caminho de dados pudessem ser compartilhadas durante a execução da instrução. Por exemplo, como mostra a Figura 4.34, a memória de instruções é usada durante apenas um dos cinco estágios de uma instrução, permitindo que seja compartilhada por outras instruções durante os outros quatro estágios. A fim de reter o valor de uma instrução individual para seus outros quatro estágios, o valor lido da memória de instruções precisa ser salvo em um registrador. Argumentos semelhantes se aplicam a cada estágio do pipeline, de modo que precisamos colocar registradores sempre que existam linhas divisórias entre os estágios na Figura 4.33. Retornando à nossa analogia da lavanderia, poderíamos ter um cesto entre cada par de estágios contendo as roupas para a próxima etapa. A Figura 4.35 mostra o caminho de dados usando pipeline com os registradores do pipeline destacados. Todas as instruções avançam durante cada ciclo de clock de um registrador do pipeline para o seguinte. Os registradores recebem os nomes dos dois estágios separados por esse registrador. Por
exemplo, o registrador do pipeline entre os estágios IF e ID é chamado de IF/ID.
FIGURA 4.35 A versão com pipeline do caminho de dados na Figura 4.33. Os registradores do pipeline, em cinza, separam cada estágio do pipeline. Eles são rotulados pelos nomes dos estágios que separam; por exemplo, o primeiro é rotulado com IF/ID porque separa os estágios de busca de instruções e decodificação de instruções. Os registradores precisam ser grandes o suficiente para armazenar todos os dados correspondentes às linhas que passam por eles. Por exemplo, o registrador IF/ID precisa ter 64 bits de largura, pois precisa manter a instrução de 32 bits lida da memória e o endereço incrementado de 32 bits no PC. Vamos expandir esses registradores no decorrer deste capítulo, mas, por enquanto, os outros três registradores de pipeline contêm 128, 97 e 64 bits, respectivamente.
Observe que não existe um registrador de pipeline no final do estágio de escrita do resultado (WB). Todas as instruções precisam atualizar algum estado no processador — o banco de registradores, memória ou o PC —, assim, um registrador de pipeline separado é redundante para o estado que é atualizado. Por exemplo, uma instrução load colocará seu resultado em um dos 32 registradores, e qualquer instrução posterior que precise desses dados simplesmente lerá o registrador apropriado. Naturalmente, cada instrução atualiza o PC, seja incrementando-o ou atribuindo a ele o endereço de destino de um desvio. O PC pode ser considerado
um registrador de pipeline: um que alimenta o estágio IF do pipeline. Contudo, diferente dos registradores de pipeline sombreados na Figura 4.35, o PC faz parte do estado arquitetônico visível; seu conteúdo precisa ser salvo quando ocorre uma exceção, enquanto o conteúdo dos registradores de pipeline pode ser descartado. Na analogia da lavanderia, você poderia pensar no PC como correspondendo ao cesto que mantém a remessa de roupas sujas antes da etapa de lavagem. Para mostrar como funciona a técnica de pipelining, no decorrer deste capítulo, apresentamos sequências de figuras para demonstrar a operação com o tempo. Essas páginas extras parecem exigir muito mais tempo para você entender. Mas não tema; as sequências levam muito menos tempo do que parece, pois você pode compará-las e ver que mudanças ocorrem em cada ciclo do clock. A Seção 4.7 descreve o que acontece quando existem hazards de dados entre instruções em um pipeline; ignore-as por enquanto. As Figuras 4.36 a 4.38, nossa primeira sequência, mostram as partes ativas do caminho de dados destacadas, enquanto uma instrução de load passa pelos cinco estágios de execução do pipeline. Mostramos um load primeiro porque ele ativa todos os cinco estágios. Como nas Figuras de 4.28 a 4.30, destacamos a metade direita dos registradores ou memória quando estão sendo lidos e destacamos a metade esquerda quando estão sendo escritos.
FIGURA 4.36 IF e ID: primeiro e segundo estágios do pipe de uma instrução, com as partes ativas do caminho de dados da Figura 4.35 em destaque. A convenção de destaque é a mesma utilizada na Figura 4.28. Como na Seção 4.2, não há confusão quando se lê e escreve nos registradores, pois o conteúdo só muda na transição do clock. Embora o load só precise do registrador de cima no estágio 2, o processador não sabe qual instrução está sendo decodificada, de modo que estende o sinal da constante de 16 bits e lê os dois registradores para o registrador de pipeline
ID/EX. Não precisamos de todos os três operandos, mas simplifica o controle manter todos os três.
FIGURA 4.37 EX: o terceiro estágio do pipe de uma instrução load, destacando as partes do caminho de dados da Figura 4.35 usadas neste estágio do pipe. O registrador é acrescentado ao imediato com sinal estendido, e a soma é colocada no registrador de pipeline EX/MEM.
FIGURA 4.38 MEM e WB: o quarto e quinto estágios do pipe de uma instrução load, destacando as partes do caminho de dados da Figura 4.35 usadas nesses estágios do pipe. A memória de dados é lida por meio do endereço no registrador de pipeline EX/MEM e os dados são colocados no registrador de pipeline MEM/WB. Em seguida, os dados são lidos do registrador de pipeline MEM/WB e escritos no banco de registradores, no meio do caminho de dados. Nota: existe um bug nesse projeto, que foi consertado na Figura 4.41.
Mostramos a abreviação da instrução lw com o nome do estágio do pipeline que está ativo em cada figura. Os cinco estágios são os seguintes: 1. Busca de instruções: A parte superior da Figura 4.36 mostra a instrução sendo lida da memória usando o endereço no PC e depois colocada no registrador de pipeline IF/ID. O endereço do PC é incrementado em 4 e, depois, escrito de volta ao PC, para que fique pronto para o próximo ciclo de clock. Esse endereço incrementado também é salvo no registrador de pipeline IF/ID caso seja necessário mais tarde para uma instrução, como beq. O computador não tem como saber que tipo de instrução está sendo buscada, de modo que precisa se preparar para qualquer instrução, passando pelo pipeline informações potencialmente necessárias. 2. Decodificação de instruções e leitura do banco de registradores: A parte inferior da Figura 4.36 mostra a parte relativa à instrução do registrador de pipeline IF/ID, fornecendo o campo imediato de 16 bits, que tem seu sinal estendido para 32 bits, e os números dos dois registradores para leitura. Todos os três valores são armazenados no registrador de pipeline ID/EX, assim como o endereço no PC incrementado. Novamente, transferimos tudo o que possa ser necessário por qualquer instrução, durante um ciclo de clock posterior. 3. Execução ou cálculo de endereço: A Figura 4.37 mostra que a instrução load lê o conteúdo do registrador 1 e o imediato com o sinal estendido do registrador de pipeline ID/EX e os soma usando a ALU. Essa soma é colocada no registrador de pipeline EX/MEM. 4. Acesso à memória: A parte superior da Figura 4.38 mostra a instrução load lendo a memória de dados por meio do endereço vindo do registrador de pipeline EX/MEM e carregando os dados no registrador de pipeline MEM/WB. 5. Escrita do resultado: A parte inferior da Figura 4.38 mostra a etapa final: lendo os dados do registrador de pipeline MEM/WB e escrevendo-os no banco de registradores, no meio da figura. Essa revisão da instrução load mostra que qualquer informação necessária em um estágio posterior do pipe precisa ser passada a esse estágio por meio de um registrador de pipeline. A revisão de uma instrução store mostra a semelhança na execução da instrução, bem como a passagem da informação para os estágios posteriores. Aqui estão os cinco estágios do pipe da instrução store: 1. Busca de instruções: A instrução é lida da memória usando o endereço no PC e depois é colocada no registrador de pipeline IF/ID. Esse estágio
ocorre antes que a instrução seja identificada, de modo que a parte superior da Figura 4.36 funciona para store e também para load. 2. Decodificação de instruções e leitura do banco de registradores: A instrução no registrador de pipeline IF/ID fornece os números de dois registradores para leitura e estende o sinal do imediato de 16 bits. Esses três valores de 32 bits são armazenados no registrador de pipeline ID/EX. A parte inferior da Figura 4.36 para instruções load também mostra as operações do segundo estágio para stores. Esses dois primeiros estágios são executados por todas as instruções, pois é muito cedo para saber o tipo da instrução. 3. Execução e cálculo de endereço: A Figura 4.39 mostra a terceira etapa; o endereço efetivo é colocado no registrador de pipeline EX/MEM.
FIGURA 4.39 EX: o terceiro estágio do pipe de uma instrução store. Ao contrário do terceiro estágio da instrução load na Figura 4.37, o segundo valor do registrador é carregado no registrador de pipeline EX/MEM a ser usado no próximo estágio. Embora não faça mal algum sempre escrever esse segundo registrador no registrador de pipeline EX/MEM, escrevemos o segundo registrador apenas em uma instrução store para tornar o pipeline mais fácil de entender.
4. Acesso à memória: A parte superior da Figura 4.40 mostra os dados sendo escritos na memória. Observe que o registrador contendo os dados a serem armazenados foi lido em um estágio anterior e armazenado no ID/EX. A única maneira de disponibilizar os dados durante o estágio MEM é colocar os dados no registrador de pipeline EX/MEM no estágio EX, assim como armazenar o endereço efetivo em EX/MEM.
FIGURA 4.40 MEM e WB: o quarto e quinto estágios do pipe de uma instrução store. No quarto estágio, os dados são escritos na memória de dados para o store. Observe que os dados vêm do registrador de pipeline EX/MEM e que nada é mudado no registrador de pipeline MEM/WB. Uma vez que os dados são escritos na memória, não há nada mais a fazer para a instrução store, de modo que nada acontece no estágio 5.
5. Escrita do resultado: A parte inferior da Figura 4.40 mostra a última etapa
do store. Para essa instrução, nada acontece no estágio de escrita do resultado. Como cada instrução por trás do store já está em progresso, não temos como acelerar essas instruções. Logo, uma instrução passa por um estágio mesmo que não haja nada a fazer, pois as instruções posteriores já estão prosseguindo em velocidade máxima. A instrução store novamente ilustra que, para passar algo de um estágio anterior do pipe a um estágio posterior, a informação precisa ser colocada em um registrador de pipeline; caso contrário, a informação é perdida quando a próxima instrução entrar nesse estágio do pipeline. Para a instrução store, precisamos passar um dos registradores lidos no estágio ID para o estágio MEM, onde é armazenado na memória. Os dados foram colocados inicialmente no registrador de pipeline ID/EX e depois passados para o registrador de pipeline EX/MEM. Load e store ilustram um segundo ponto importante: cada componente lógico do caminho de dados — como memória de instruções, portas para leitura de registradores, ALU, memória de dados e porta para escrita de registradores — só pode ser usado dentro de um único estágio do pipeline. Caso contrário, teríamos um hazard estrutural (ver Seção Hazards estruturais”, anteriormente neste capítulo). Logo, esses componentes e seu controle podem ser associados a um único estágio do pipeline. Agora, podemos expor um bug no projeto da instrução load. Você conseguiu ver? Qual registrador é alterado no estágio final da leitura? Mais especificamente, qual instrução fornece o número do registrador de escrita? A instrução no registrador de pipeline IF/ID fornece o número do registrador de escrita, embora essa instrução ocorra consideravelmente depois da instrução load! Logo, precisamos preservar o número do registrador de destino da instrução load. Assim como store passou o conteúdo do registrador do ID/EX aos registradores de pipeline EX/MEM para uso no estágio MEM, load precisa passar o número do registrador de ID/EX por EX/MEM ao registrador de pipeline MEM/WB, para uso no estágio WB. Outra maneira de pensar sobre a passagem do número de registrador é que, para compartilhar o caminho de dados em pipeline, precisávamos preservar a instrução lida durante o estágio IF, de modo que cada registrador de pipeline contenha uma parte da instrução necessária para esse estágio e para os estágios posteriores. A Figura 4.41 mostra a versão correta do caminho de dados, passando o número do registrador de escrita primeiro ao registrador ID/EX, depois ao registrador EX/MEM e finalmente ao registrador MEM/WB. O número do
registrador é usado durante o estágio WB de modo a especificar o registrador a ser escrito. A Figura 4.42 é um desenho simples do caminho de dados corrigido, destacando o hardware utilizado em todos os cinco estágios da instrução load word nas Figuras de 4.36 a 4.38. Veja na Seção 4.8 uma explicação de como fazer a instrução branch funcionar como esperado.
FIGURA 4.41 O caminho de dados em pipeline corrigido para lidar corretamente com a instrução load. O número do registrador de escrita agora vem do registrador de pipeline MEM/WB junto com os dados. O número do registrador é passado do estágio do pipe ID até alcançar o registrador de pipeline MEM/WB, acrescentando mais 5 bits aos três últimos registradores de pipeline. Esse novo caminho aparece em destaque.
FIGURA 4.42 A parte do caminho de dados na Figura 4.41 usada em todos os cinco estágios de uma instrução load.
Representando pipelines graficamente Pipelining pode ser difícil de entender, pois muitas instruções estão executando simultaneamente em um único caminho de dados em cada ciclo de clock. Para ajudar na compreensão, existem dois estilos básicos de figuras de pipeline: diagramas de pipeline com múltiplos ciclos de clock, como a Figura 4.34, e diagramas de pipeline com único ciclo de clock, como as Figuras de 4.36 a 4.40. Os diagramas com múltiplos ciclos de clock são mais simples, mas não contêm todos os detalhes. Por exemplo, considere esta sequência de cinco instruções:
A Figura 4.43 mostra o diagrama de pipeline com múltiplos ciclos de clock para essas instruções. O tempo avança da esquerda para a direita na horizontal, semelhante ao pipeline da lavanderia, na Figura 4.25. Uma representação dos estágios do pipeline é colocada em cada parte do eixo de instruções, ocupando os ciclos de clock apropriados. Esses caminhos de dados estilizados representam os cinco estágios do nosso pipeline, mas um retângulo indicando o nome de cada estágio do pipe também funciona bem. A Figura 4.44 mostra a versão mais tradicional do diagrama de pipeline com múltiplos ciclos de clock. Observe que a Figura 4.43 mostra os recursos físicos utilizados em cada estágio, enquanto a Figura 4.44 usa o nome de cada estágio.
FIGURA 4.43 Diagrama de pipeline com múltiplos ciclos de clock das cinco instruções. Este estilo de representação de pipeline mostra a execução completa das instruções em uma única figura. As instruções são listadas por ordem de execução, de cima para baixo e os ciclos de clock se movem da esquerda para a direita. Ao contrário da Figura 4.28, aqui, mostramos os registradores de pipeline entre cada estágio. A Figura 4.44 mostra a maneira tradicional de
desenhar esse diagrama.
FIGURA 4.44 Diagrama de pipeline tradicional com múltiplos ciclos de clock, com as cinco instruções da Figura 4.43.
Os diagramas de pipeline de ciclo único de clock mostram o estado do caminho de dados inteiro durante um único ciclo de clock, e normalmente todas as cinco instruções no pipeline são identificadas por rótulos acima de seus respectivos estágios do pipeline. Usamos esse tipo de figura para mostrar os detalhes do que está acontecendo dentro do pipeline durante cada ciclo de clock; normalmente, os desenhos aparecem em grupos, para mostrar a operação do pipeline durante uma sequência de ciclos de clock. Usamos diagramas de ciclo múltiplo de clock, a fim de oferecer sinopses de situações de pipelining. Um diagrama de ciclo único de clock representa uma fatia vertical de um conjunto do diagrama com múltiplos ciclos de clock, mostrando o uso do caminho de dados em cada uma das instruções do pipeline no ciclo de clock designado. Por exemplo, a Figura 4.45 mostra o diagrama com ciclo único de clock correspondente ao ciclo de clock 5 das Figuras 4.43 e 4.44. Obviamente, os diagramas com ciclo único de clock possuem mais detalhes e ocupam muito mais espaço para mostrar o mesmo número de ciclos de clock. Os exercícios pedem que você crie esses diagramas para outras sequências de código.
FIGURA 4.45 O diagrama com ciclo único de clock correspondente ao ciclo de clock 5 do pipeline das Figuras 4.43 e 4.44. Como você pode ver, uma figura com ciclo único de clock é uma fatia vertical de um diagrama com múltiplos ciclos de clock.
Verifique você mesmo Um grupo de alunos discutia sobre a eficiência de um pipeline de cinco estágios quando um deles apontou que nem todas as instruções estão ativas em cada estágio do pipeline. Depois de decidir ignorar os efeitos dos hazards, eles fizeram as quatro afirmações a seguir. Quais delas estão corretas? 1. Permitir que jumps, branches e instruções da ALU utilizem menos estágios do que os cinco necessários pela instrução load, aumentará o desempenho do pipeline sob todas as circunstâncias. 2. Tentar permitir que algumas instruções utilizem menos ciclos não ajuda, pois a vazão é determinada pelo ciclo do clock; o número de estágios do pipe por instrução afeta a latência, e não a vazão. 3. Você não pode fazer com que as instruções da ALU utilizem menos ciclos, devido à escrita do resultado, mas os branches e jumps podem utilizar menos ciclos, de modo que existe alguma oportunidade de melhoria. 4. Em vez de tentar fazer com que as instruções utilizem menos ciclos de clock, devemos explorar um meio de tornar o pipeline mais longo, de
modo que as instruções utilizem mais ciclos, porém com ciclos mais curtos. Isso poderia melhorar o desempenho.
Controle em pipeline No computador 6600, talvez ainda mais do que em qualquer computador anterior, o sistema de controle faz a diferença. James Thornton, Design of a Computer: The Control Data 6600, 1970
Assim como acrescentamos controle ao caminho de dados simples na Seção 4.3, agora acrescentamos controle ao caminho de dados de um pipeline. Começamos com um projeto simples, que vê o problema por meio de óculos cor-de-rosa. O primeiro passo é rotular as linhas de controle no caminho de dados existente. A Figura 4.46 mostra essas linhas. Pegamos o máximo possível emprestado do controle para o caminho de dados simples da Figura 4.17. Em particular, usamos a mesma lógica de controle da ALU, lógica de desvio, multiplexador do registrador destino e linhas de controle. Essas funções são definidas nas Figuras 4.12, 4.16 e 4.18. Reproduzimos as principais informações nas Figuras 4.47 a 4.49 em uma única página, de modo a facilitar o acompanhamento do restante do texto.
FIGURA 4.46 O caminho de dados em pipeline da Figura 4.41 com sinais de controle identificados. Esse caminho de dados toma emprestado a lógica de controle para a origem do PC, o número do registrador destino e o controle da ALU, da Seção 4.4. Observe que agora precisamos do campo funct (código de função) de 6 bits da instrução no estágio EX como entrada para o controle da ALU, de modo que esses bits também precisam ser incluídos no registrador de pipeline ID/EX. Lembre-se de que esses 6 bits também são os 6 bits menos significativos do campo imediato da instrução, de modo que o registrador de pipeline ID/EX pode fornecê-los a partir do campo imediato, já que a extensão do sinal deixa esses bits inalterados.
FIGURA 4.47 Uma cópia da Figura 4.12. Essa figura mostra como os bits do controle da ALU são definidos dependendo dos bits de controle ALUOp e dos
diferentes códigos de função para instruções tipo R.
FIGURA 4.48 Uma cópia da Figura 4.16. A função de cada um dos sete sinais de controle é definida. As linhas de controle da ALU (ALUOp) são definidas na segunda coluna da Figura 4.47. Quando um controle de 1 bit para um multiplexador bidirecional é ativado, o multiplexador seleciona a entrada correspondente a 1. Caso contrário, se o controle for desativado, o multiplexador seleciona a entrada 0. Observe que PCScr é controlado por uma porta lógica AND na Figura 4.46. Se o sinal Branch e o sinal Zero da ALU estiverem ativos, então PCScr é 1; caso contrário, ele é 0. O controle define o sinal Branch somente durante uma instrução beq; caso contrário, o PCScr é 0.
FIGURA 4.49 Os valores das linhas de controle são iguais aos da Figura 4.18, mas foram reorganizados em três grupos, correspondentes aos três últimos estágios do pipeline.
Assim como ocorreu com a implementação com ciclo único, consideramos que o PC é escrito a cada ciclo de clock, de modo que não existe um sinal de
escrita separado para o PC. Pelo mesmo argumento, não existem sinais de escrita para os registradores de pipeline (IF/ID, ID/EX, EX/MEM e MEM/WB), pois os registradores de pipeline também são escritos durante cada ciclo de clock. A fim de especificar o controle para o pipeline, só precisamos definir os valores de controle durante cada estágio do pipeline. Como cada linha de controle está associada a um componente ativo em apenas um estágio do pipeline, podemos dividir as linhas de controle em cinco grupos, de acordo com o estágio do pipeline. 1. Busca de instruções: Os sinais de controle para ler a memória de instruções e escrever o PC sempre são ativados, de modo que não existe nada de especial para controlar nesse estágio do pipeline. 2. Decodificação de instruções/leitura do banco de registradores: Como no estágio anterior, o mesmo acontece em cada ciclo de clock, de modo que não existem linhas de controle opcionais para definir. 3. Execução/cálculo de endereço: Os sinais a serem definidos são RegDst, ALUOp e ALUScr (Figuras 4.47 e 4.48). Os sinais selecionam o registrador Resultado, a operação da ALU e Dados da leitura 2 ou um imediato com sinal estendido para a ALU. 4. Acesso à memória: As linhas de controle definidas nesse estágio são Branch, ReadMem e WriteMem. Esses sinais são definidos pelas instruções branch equal, load e store, respectivamente. Lembre-se de que o PCScr na Figura 4.48 seleciona o próximo endereço sequencial, a menos que o controle ative Branch e o resultado da ALU seja zero. 5. Escrita do resultado: As duas linhas de controle são MemtoReg, que decide entre enviar o resultado da ALU ou o valor da memória para o banco de registradores, e WriteRegWriteReg, que escreve o valor escolhido. Como a utilização de um pipeline no caminho de dados deixa inalterado o significado das linhas de controle, podemos usar os mesmos valores de controle de antes. A Figura 4.49 tem os mesmos valores da Seção 4.4, mas agora as nove linhas de controle estão agrupadas por estágio do pipeline. A implementação do controle significa definir as nove linhas de controle desses valores em cada estágio, para cada instrução. A maneira mais simples de fazer isso é estender os registradores do pipeline de modo a incluir informações de controle. Como as linhas de controle começam com o estágio EX, podemos criar a informação de controle durante a decodificação da instrução. A Figura 4.50
mostra que esses sinais de controle são usados no respectivo estágio do pipeline, à medida que a instrução se move por ele, assim como o número do registrador destino para loads desce pelo pipeline da Figura 4.41. A Figura 4.51 mostra o caminho de dados completo, com os registradores de pipeline estendidos e com as linhas de controle conectadas ao estágio apropriado
FIGURA 4.50 As linhas de controle para os três estágios finais. Observe que quatro das nove linhas de controle são usadas na fase EX, com as cinco linhas de controle restantes passadas adiante para o registrador de pipeline EX/MEM, a fim de manter as linhas de controle; três são usadas durante o estágio MEM, e as duas últimas são passadas a MEM/WB, para uso no estágio WB.
FIGURA 4.51 O caminho de dados em pipeline da Figura 4.46, com os sinais de controle conectados às partes de controle dos registradores de pipeline. Os valores de controle para os três últimos estágios são criados durante o estágio de decodificação de instruções e, depois, colocados no registrador de pipeline ID/EX. As linhas de controle para cada estágio do pipe são usadas, e as linhas de controle restantes depois disso são passadas ao próximo estágio do pipeline.
4.7. Hazards de dados: forwarding versus stalls Como assim por que teve de ser criado? É um bypass. Você precisa criar bypasses. Douglas Adams, The Hitchhiker’s Guide to the Galaxy, 1979
Os exemplos da seção anterior mostram o poder da execução em pipeline e como o hardware realiza a tarefa. Agora é hora de retirarmos os óculos cor-derosa e examinarmos o que acontece com os programas reais. As instruções nas
Figuras de 4.43 a 4.45 eram independentes; nenhuma delas usava os resultados calculados por qualquer uma das outras. Mesmo assim, na Seção 4.5, vimos que os hazards de dados são obstáculos para a execução em pipeline. Vejamos uma sequência com muitas dependências, indicadas com realce:
As quatro últimas instruções são todas dependentes do resultado no registrador $2 da primeira instrução. Se o registrador $2 tivesse o valor 10 antes da instrução subtract e -20 depois dela, o programador desejaria que -20 fosse usado nas instruções seguintes, que se referem ao registrador $2. Como essa sequência funcionaria com nosso pipeline? A Figura 4.52 ilustra a execução dessas instruções usando uma representação de pipeline com múltiplos ciclos de clock. Para demonstrar a execução dessa sequência de instruções em nosso pipeline atual, o topo da Figura 4.52 mostra o valor do registrador $2, que muda durante o ciclo de clock 5, quando a instrução sub escreve seu resultado.
FIGURA 4.52 Dependências em pipeline em uma sequência de cinco instruções usando caminhos de dados simplificados para mostrar as dependências. Todas as ações dependentes são mostradas em cinza, e “CC 1” no alto da figura significa o ciclo de clock 1. A primeira instrução escreve em $2, e todas as instruções seguintes leem de $2. Esse registrador é escrito no ciclo de clock 5, de modo que o valor correto está indisponível antes do ciclo de clock 5. (Uma leitura de um registrador durante um ciclo de clock retorna o valor escrito no final da primeira metade do ciclo, quando ocorre tal escrita.) As linhas coloridas do caminho de dados do topo para os inferiores mostram as dependências. Aquelas que precisam retornar no tempo são os hazards de dados do pipeline.
O último hazard em potencial pode ser resolvido pelo projeto do hardware do banco de registradores: o que acontece quando um registrador é lido e escrito no mesmo ciclo de clock? Consideramos que a escrita está na primeira metade do ciclo de clock e a leitura está na segunda metade, de modo que esta fornece o que foi escrito. Como acontece para muitas implementações dos bancos de registradores, não temos hazard de dados nessa situação. A Figura 4.52 mostra que os valores lidos para o registrador $2 não seriam o
resultado da instrução sub, a menos que a leitura ocorresse durante o ciclo de clock 5 ou posterior. Assim, as instruções que receberiam o valor correto de -20 são add e sw; as instruções AND e OR receberiam o valor incorreto de 10! Usando esse estilo de desenho, esses problemas se tornam aparentes quando uma linha de dependência retorna no tempo. Conforme dissemos na Seção 4.5, o resultado desejado está disponível no final do estágio EX ou no ciclo de clock 3. Quando os dados são realmente necessários pelas instruções AND e OR? No início do estágio EX ou nos ciclos de clock 4 e 5, respectivamente. Assim, podemos executar esse segmento sem stalls se simplesmente os dados sofrerem forwarding assim que estiverem disponíveis para quaisquer unidades que precisam deles, antes de estarem disponíveis para leitura do banco de registradores. Como funciona o forwarding? Para simplificar o restante desta seção, consideramos apenas o desafio de forwarding para uma operação no estágio EX, que pode ser uma operação da ALU ou um cálculo de endereço efetivo. Isso significa que, quando uma instrução tenta usar um registrador em seu estágio EX, que uma instrução anterior pretende escrever em seu estágio WB, na realidade precisamos dos valores como entradas para a ALU. Uma notação que nomeia os campos dos registradores de pipeline permite uma notação mais precisa das dependências. Por exemplo, “ID/EX.RegistradorRs” refere-se ao número de um registrador cujo valor se encontra no registrador de pipeline ID/EX; ou seja, aquele da primeira porta de leitura do banco de registradores. A primeira parte do nome, à esquerda do ponto, é o nome do registrador de pipeline; a segunda parte é o nome do campo nesse registrador. Usando essa notação, os dois pares de condições de hazard são: 1a. EX/MEM.RegistradorRd = ID/EX.RegistradorRs 1b. EX/MEM.RegistradorRd = ID/EX.RegistradorRt 2a. MEM/WB.RegistradorRd = ID/EX.RegistradorRs 2b. MEM/WB.RegistradorRd = ID/EX.RegistradorRt O primeiro hazard na sequência da Seção 4.7 está no registrador $2, entre o resultado de sub $2,$1,$3 e o primeiro operando de leitura de and $12,$2,$5. Esse hazard pode ser detectado quando a instrução and está no estágio EX, e a instrução anterior está no estágio MEM, de modo que este é o hazard 1a: EX/MEM.RegistradorRd = ID/EX.RegistradorRs = $2
Detecção de dependência Exemplo Classifique as dependências nesta sequência da Seção 4.7:
Resposta Conforme já mencionamos, o sub-and é um hazard tipo 1a. Os outros hazards são ▪ sub-or é um hazard tipo 2b: MEM/WB.RegistradorRd = ID/EX.RegistradorRt = $2 ▪ As duas dependências em sub-add não são hazards, pois o banco de registradores fornece os dados apropriados durante o estágio ID de add. ▪ Não existe hazard de dados entre sub e sw, porque sw lê $2 no ciclo de clock depois que sub escreve $2. Como algumas instruções não escrevem em registradores, essa política não é exata; às vezes, poderia haver forwarding indevidamente. Uma solução é simplesmente verificar se o sinal WriteReg estará ativo: examinando o campo de controle WB do registrador de pipeline durante os estágios EX e MEM, é possível determinar se WriteReg está ativo. Lembre-se de que o MIPS exige que cada uso de $0 como operando deve gerar um valor de operando 0. Se uma instrução no pipeline tiver $0 como seu destino (por exemplo, sll $0,$1,2), queremos evitar o forwarding do seu valor possivelmente diferente de zero. Não encaminhar os resultados destinados a $0 libera o programador assembly e o compilador de qualquer requisito para evitar o uso de $0 como destino. As condições anteriores, portanto, funcionam corretamente desde que acrescentemos EX/MEM.RegistradorRd ≠ 0 à primeira condição de hazard e MEM/WB.RegistradorRd ≠ 0 à segunda.
Agora que podemos detectar os hazards, metade do problema está resolvido — mas ainda precisamos fazer o forwarding dos dados corretos. A Figura 4.53 mostra as dependências entre os registradores de pipeline e as entradas da ALU para a mesma sequência de código da Figura 4.52. A mudança é que a dependência começa por um registrador de pipeline, em vez de esperar pelo estágio WB para escrever no banco de registradores. Assim, os dados exigidos existem a tempo para as instruções posteriores, com os registradores de pipeline mantendo os dados para forwarding.
FIGURA 4.53 As dependências entre os registradores de pipeline se movem para a frente no tempo, de modo que é possível fornecer as entradas para a ALU necessárias para a instrução AND e para a instrução OR fazendo forwarding dos resultados encontrados nos registradores de pipeline. Os valores nos registradores de pipeline mostram que o valor desejado está disponível antes de ser escrito no banco de registradores. Consideramos que o banco de registradores encaminha valores lidos e escritos durante o mesmo ciclo de
clock, de modo que add não causa stall, mas os valores vêm do banco de registradores e não de um registrador de pipeline. O “forwarding” do banco de registradores — ou seja, a leitura apanha o valor da escrita nesse ciclo de clock — é o motivo pelo qual o ciclo de clock 5 mostra o registrador $2, tendo o valor 10 no início e -20 no final do ciclo de clock. Como no restante desta seção, tratamos de todo o forwarding, exceto o valor a ser armazenado por uma instrução store.
Se pudermos pegar as entradas da ALU de qualquer registrador de pipeline, e não apenas de ID/EX, então podemos fazer o forwarding dos dados corretos. Acrescentando multiplexadores à entrada da ALU e com os controles apropriados, podemos executar o pipeline em velocidade máxima na presença dessas dependências de dados. Por enquanto, vamos considerar que as únicas instruções para as quais precisamos de forwarding são as quatro instruções no formato R: add, sub, AND e OR. A Figura 4.54 mostra um detalhe da ALU e do registrador de pipeline, antes e depois de acrescentar o forwarding. A Figura 4.55 mostra os valores das linhas de controle para os multiplexadores da ALU que selecionam os valores do banco de registradores ou um dos valores de forwarding.
FIGURA 4.54 Em cima estão a ALU e os registradores de pipeline antes da inclusão do forwarding. Embaixo, os multiplexadores foram expandidos para acrescentar os caminhos de forwarding e mostramos a unidade de forwarding. O hardware novo aparece em um destaque. No
entanto, essa figura é um desenho estilizado, omitindo os detalhes do caminho de dados completo, como o hardware de extensão de sinal. Observe que o campo ID/EX.RegistradorRt aparece duas vezes, uma para conectar ao Mux e uma para a unidade de forwarding, mas esse é um sinal único. Como na discussão anterior, isso ignora o forwarding de um valor armazenado por uma instrução store. Observe também que esse mecanismo funciona, inclusive, para instruções slt.
FIGURA 4.55 Os valores de controle para os multiplexadores de forwarding da Figura 4.54. O imediato com sinal que é outra entrada da ALU é descrito na Seção “Detalhamento” ao final desta seção.
Esse controle de forwarding estará no estágio EX porque os multiplexadores de forwarding da ALU são encontrados nesse estágio. Assim, temos de passar os números dos registradores operando do estágio ID por meio do registrador de pipeline ID/EX, para determinar se os valores devem sofrer forwarding. Já temos o campo rt (bits 20-16). Antes do forwarding, o registrador ID/EX não precisava incluir espaço a fim de manter o campo rs. Logo, rs (bits 25-21) é acrescentado a ID/EX. Agora, vamos escrever as duas condições para detectar hazards e os sinais de controle para resolvê-los: 1. Hazard EX:
Observe que o campo EX/MEM.RegistradorRd é o destino de registrador para uma instrução da ALU (que vem do campo Rd da instrução) ou um load (que vem do campo Rt). Esse caso faz o forwarding do resultado da instrução anterior para qualquer entrada da ALU. Se a instrução anterior tiver de escrever no banco de registradores e o número do registrador de escrita combinar com o número do registrador de leitura das entradas A ou B da ALU, desde que não seja o registrador 0, então direcione o multiplexador para que pegue o valor do registrador de pipeline EX/MEM. 2. Hazard MEM:
Como dissemos, não existe hazard no estágio WB porque consideramos que o banco de registradores fornece o resultado correto se a instrução no estágio ID ler o mesmo registrador escrito pela instrução no estágio WB. Tal banco de registradores realiza outra forma de forwarding, mas isso ocorre dentro do banco de registradores. Uma complicação são os hazards de dados em potencial entre o resultado da instrução no estágio WB, o resultado da instrução no estágio MEM e o operando de origem da instrução no estágio ALU. Por exemplo, ao somar um vetor de números em um único registrador, uma sequência de instruções lerá e escreverá no mesmo registrador:
Nesse caso, o resultado sofre forwarding do estágio MEM, pois o resultado no estágio MEM é o mais recente. Assim, o controle para o hazard em MEM seria (com os acréscimos destacados):
A Figura 4.56 mostra o hardware necessário para dar suporte ao forwarding para operações que utilizam resultados durante o estágio EX. Observe que o campo EX/MEM.RegistradorRd é o destino do registrador para uma instrução ALU (que vem do campo Rd da instrução) ou um load (que vem do campo Rt).
FIGURA 4.56 O caminho de dados modificado para resolver os hazards via forwarding. Em comparação com o caminho de dados da Figura 4.51, os acréscimos são os multiplexadores para as entradas da ALU. Contudo, essa figura é um desenho mais estilizado, omitindo detalhes do caminho de dados completo, como o hardware de desvio e o hardware de extensão de sinal.
Detalhamento O forwarding também pode ajudar com hazards quando instruções store dependem de outras instruções. Como elas utilizam apenas um valor de dados durante o estágio MEM, o forwarding é fácil. Mas considere os loads imediatamente seguidos por stores, útil quando se realiza cópias da memória para a memória na arquitetura MIPS. Como as cópias são frequentes, precisamos acrescentar mais hardware de forwarding, a fim de fazer com que as cópias de memória para memória se tornem mais rápidas. Se tivéssemos de redesenhar a Figura 4.53, substituindo as instruções sub e AND por lw e sw, veríamos que é possível evitar um stall, pois os dados existem no registrador MEM/WB de uma instrução load, em tempo para seu uso no estágio MEM de uma instrução store. Para essa opção, teríamos de acrescentar o forwarding para o estágio de acesso à memória. Deixamos essa modificação como um exercício para o leitor.
Além disso, a entrada imediata com sinal para a ALU, necessária para loads e stores, não existe no caminho de dados da Figura 4.56. Como o controle central decide entre registrador e imediato, e como a unidade de forwarding escolhe o registrador de pipeline para uma entrada de registrador para a ALU, a solução mais fácil é acrescentar um multiplexador 2:1 que escolha entre a saída do multiplexador ForwardB e o imediato com sinal. A Figura 4.57 mostra esse acréscimo.
FIGURA 4.57 Uma visão de perto do caminho de dados da Figura 4.54 mostra um multiplexador 2:1, que foi acrescentado para selecionar o imediato com sinal como uma entrada para a ALU
Hazards de dados e stalls Se a princípio você não obteve sucesso, redefina sucesso. Anônimo
Conforme dissemos na Seção 4.5, um caso em que o forwarding não pode salvar o dia é quando uma instrução tenta ler um registrador após uma instrução load que escreve no mesmo registrador. A Figura 4.58 ilustra o problema. Os dados ainda são lidos da memória no ciclo de clock 4, enquanto a ALU está realizando a operação para a instrução seguinte. Algo precisa ocasionar um stall no pipeline para a combinação de load seguida por uma instrução que lê seu resultado.
FIGURA 4.58 Uma sequência de instruções em pipeline. Como a dependência entre o load e a instrução seguinte (and) recua no tempo, esse hazard não pode ser resolvido pelo forwarding. Logo, essa combinação precisa resultar em um stall pela unidade de detecção de hazard.
Logo, além de uma unidade de forwarding, precisamos de uma unidade de detecção de hazard. Ela opera durante o estágio ID, de modo que pode inserir o stall entre o load e seu uso. Verificando as instruções load, o controle para a unidade de detecção de hazard é esta condição única:
A primeira linha testa se a instrução é um load: a única instrução que lê a memória de dados é um load. As duas linhas seguintes verificam se o campo do registrador destino do load no estágio EX combina com qualquer registrador origem da instrução no estágio ID. Se a condição permanecer, a instrução ocasiona um stall de um ciclo de clock no pipeline. Depois desse stall de um ciclo, a lógica de forwarding pode lidar com a dependência e a execução prossegue. (Se não houvesse forwarding, então as instruções na Figura 4.58 precisariam de outro ciclo de stall.) Se a instrução no estágio ID sofrer um stall, então a instrução no estágio IF também precisa sofrer; caso contrário, perderíamos a instrução lida da memória. Evitar que essas duas instruções tenham progresso é algo feito simplesmente impedindo-se que o registrador PC e o registrador de pipeline IF/ID sejam alterados. Desde que esses registradores sejam preservados, a instrução no estágio IF continuará a ser lida usando o mesmo PC, e os registradores no estágio ID continuarão a ser lidos usando os mesmos campos de instrução no registrador de pipeline IF/ID. Retornando à nossa analogia favorita, é como se você reiniciasse a lavadora com as mesmas roupas e deixasse a secadora continuar a trabalhar vazia. Naturalmente, assim como a secadora, a metade do pipeline que começa com o estágio EX precisa estar fazendo algo; o que ela está fazendo é executar instruções que não têm efeito algum: nops.
nop Uma instrução que não realiza operação para mudar de estado. Como podemos inserir esses nops, que atuam como bolhas, no pipeline? Na Figura 4.49, vimos que a desativação de todos os nove sinais de controle (colocando-os em 0) nos estágios EX, MEM e WB criará uma instrução que “não faz nada” ou nop. Identificando o hazard no estágio ID, podemos inserir uma bolha no pipeline alterando os campos de controle EX, MEM e WB do registrador de pipeline ID/EX para 0. Esses valores de controle benignos são filtrados adiante em cada ciclo de clock com o efeito correto: nenhum registrador
ou memória serão modificados se os valores forem todos 0. A Figura 4.59 mostra o que realmente acontece no hardware: o slot de execução do pipeline associado com a instrução AND transforma-se em um nop, e todas as instruções começando com a instrução AND são atrasadas um ciclo. Assim como uma bolha de ar em um cano de água, uma bolha de stall retarda tudo o que está atrás dela e prossegue pelo pipe de instruções um estágio a cada ciclo, até que saia no final. Neste exemplo, o hazard força as instruções AND e OR a repetir no ciclo de clock 4 o que fizeram no ciclo de clock 3: AND lê registradores e decodifica, e OR é apanhado novamente da memória de instruções. Esse trabalho repetido é um stall, mas seu efeito é esticar o tempo das instruções AND e OR e atrasar a busca da instrução add.
FIGURA 4.59 O modo como os stalls são realmente inseridos no pipeline. Uma bolha é inserida a partir do ciclo de clock 4, alterando a instrução and para um nop. Observe que a instrução and na realidade é buscada e decodificada nos ciclos de clock 2 e 3, mas seu estágio EX é atrasado até o ciclo de clock 5 (ao contrário da posição sem stall no ciclo de clock 4). Da mesma forma, a instrução or é apanhada no ciclo de clock 3, mas seu estágio ID é atrasado até o ciclo de clock 5 (ao contrário da
posição não atrasada no ciclo de clock 4). Após a inserção da bolha, todas as dependências seguem à frente no tempo, e nenhum outro hazard acontece.
A Figura 4.60 destaca as conexões do pipeline para a unidade de detecção de hazard e a unidade de forwarding. Como antes, a unidade de forwarding controla os multiplexadores da ALU, a fim de substituir o valor de um registrador de uso geral pelo valor do registrador de pipeline apropriado. A unidade de detecção de hazard controla a escrita dos registradores PC e IF/ID mais o multiplexador que escolhe entre os valores de controle reais e 0s. A unidade de detecção de hazard insere um stall e desativa os campos de controle se o teste de hazard do uso do load for verdadeiro.
FIGURA 4.60 Visão geral do controle em pipeline, mostrando os dois multiplexadores para forwarding, a unidade de detecção de hazard e a unidade de forwarding. Embora os estágios ID e EX tenham sido simplificados — a lógica de extensão de sinal imediato e de desvio estão faltando —, este desenho mostra a essência dos requisitos do hardware de forwarding.
Colocando em perspectiva Embora o compilador geralmente conte com o hardware para resolver dependências de hazard e garantir assim a execução correta, o compilador precisa compreender o pipeline, a fim de alcançar o melhor desempenho. Caso contrário, stalls inesperados reduzirão o desempenho do código compilado.
Detalhamento Com relação ao comentário anterior sobre a colocação das linhas de controle em 0 para evitar a escrita de registradores ou memória: somente os sinais WriteReg e WriteMem precisam ser 0, enquanto os outros sinais de controle podem ser “don’t care”.
4.8. Hazards de controle Para cada mal que está batendo na raiz há milhares pendurados nos galhos. Henry David Thoreau, Walden, 1854
Até aqui, preocupamo-nos apenas com os hazards envolvendo operações aritméticas e transferências de dados. Entretanto, como vimos na Seção 4.5, também existem hazards de pipeline envolvendo desvios. A Figura 4.61 mostra uma sequência de instruções e indica quando o desvio ocorreria nesse pipeline. Uma instrução precisa ser buscada a cada ciclo de clock para sustentar o pipeline, embora, em nosso projeto, a decisão sobre o desvio não ocorra até o estágio MEM do pipeline. Conforme mencionamos na Seção 4.5, esse atraso para determinar a instrução própria a ser buscada é chamado de hazard de controle ou hazard de desvio, ao contrário dos hazards de dados, que acabamos de examinar.
FIGURA 4.61 O impacto do pipeline sobre a instrução branch. Os números à esquerda da instrução (40, 44, …) são os endereços das instruções. Como a instrução branch decide se deve desviar no estágio MEM — ciclo de clock 4 para a instrução beq, anterior —, as três instruções sequenciais que seguem o branch serão buscadas e iniciarão sua execução. Sem intervenção, essas três instruções seguintes começarão a executar antes que o beq desvie para lw na posição 72. (A Figura 4.31 considerou um hardware extra para reduzir o hazard de controle a um ciclo de clock; esta figura usa o caminho de dados não otimizado.)
Esta seção sobre hazards de controle é mais curta do que as seções anteriores, sobre hazards de dados, porque os hazards de controle são relativamente simples de entender, e ocorrem com menos frequência que os hazards de dados. Além disso, não há nada tão eficiente contra os hazards de controle como o forwarding é contra os hazards de dados. Logo, usamos esquemas mais simples. Veremos dois esquemas para resolver os hazards de controle e uma otimização para melhorar esses esquemas.
Considere que o desvio não foi tomado
Considere que o desvio não foi tomado
Como vimos na Seção 4.5, fazer um stall até que o desvio termine é muito lento. Uma melhoria comum ao stall do desvio é prever que o desvio não será tomado e, portanto, continuar no fluxo sequencial das instruções. Se o desvio for tomado, as instruções que estão sendo buscadas e decodificadas precisam ser descartadas. A execução continua no destino do desvio. Se os desvios não são tomados na metade das vezes, e se custar pouco descartar as instruções, essa otimização reduz ao meio o custo dos hazards de controle. Para descartar instruções, simplesmente alteramos os valores de controle para 0, assim como fizemos para o stall no hazard de dados no caso do load. A diferença é que também precisamos alterar as três instruções nos estágios IF, ID e EX quando o desvio atingir o estágio MEM; para os stalls no uso de load, simplesmente alteramos o controle para 0 no estágio ID e o deixamos prosseguir no pipeline. Descartar instruções, então, significa que precisamos ser capazes de dar flush nas instruções nos estágios IF, ID e EX do pipeline.
flush Descarte de instruções em um pipeline, normalmente devido a um evento inesperado.
Reduzindo o atraso dos desvios Uma forma de melhorar o desempenho do desvio é reduzir o custo do desvio tomado. Até aqui, consideramos que o próximo PC para um desvio é selecionado no estágio MEM, mas, se movermos a execução do desvio para um estágio anterior do pipeline, então menos instruções precisam sofrer flush. A arquitetura do MIPS foi criada para dar suporte a desvios rápidos de ciclo único, que poderiam passar pelo pipeline com uma pequena penalidade no desvio. Os projetistas observaram que muitos desvios contam apenas com testes simples (igualdade ou sinal, por exemplo) e que esses testes não exigem uma operação completa da ALU, mas podem ser feitos com, no máximo, algumas portas lógicas. Quando uma decisão de desvio mais complexa é exigida, a comparação realizada por uma ALU, através de instrução separada, é requisitada — uma situação semelhante ao uso de códigos de condição para os desvios (Capítulo 2). Levar a decisão do desvio para cima exige que duas ações ocorram mais cedo: calcular o endereço de destino do desvio e avaliar a decisão do desvio. A parte fácil dessa mudança é subir com o cálculo do endereço de desvio. Já temos o valor do PC e o campo imediato no registrador de pipeline IF/ID, de modo que só movemos o somador do desvio do estágio EX para o estágio ID; naturalmente, o cálculo do endereço de destino do desvio será realizado para todas as instruções, mas só será usado quando for necessário. A parte mais difícil é a própria decisão do desvio. Para branch equal, compararíamos os dois registradores lidos durante o estágio ID para ver se são iguais. A igualdade pode ser testada, primeiro realizando um OR exclusivo de seus respectivos bits e, depois, um OR de todos os resultados. Mover o teste de desvio para o estágio ID implica hardware adicional de forwarding e detecção de hazard, visto que um desvio dependente de um resultado ainda no pipeline precisará funcionar corretamente com essa otimização. Por exemplo, a fim de implementar branch-on-equal (e seu inverso), teremos de fazer um forwarding dos resultados para a lógica do teste de igualdade que opera durante o estágio ID. Existem dois fatores que comprometem o procedimento: 1. Durante o estágio ID, temos de decodificar a instrução, decidir se é necessário um bypass para a unidade de igualdade e completar a
comparação de igualdade de modo que, se a instrução for um desvio, possamos atribuir ao PC o endereço de destino do desvio. O forwarding para os operandos dos desvios foi tratado anteriormente pela lógica de forwarding da ALU, mas a introdução da unidade de teste de igualdade no estágio ID exigirá nova lógica de forwarding. Observe que os operandosfonte de um desvio que sofreram bypass podem vir dos latches do pipeline ALU/MEM ou MEM/WB. 2. Como os valores em uma comparação de desvio são necessários durante o estágio ID, mas podem ser produzidos mais adiante no tempo, é possível que ocorra um hazard de dados e um stall seja necessário. Por exemplo, se uma instrução da ALU imediatamente antes de um desvio produz um dos operandos para a comparação no desvio, um stall será exigido, já que o estágio EX para a instrução da ALU ocorrerá depois do ciclo de ID do desvio. Por extensão, se um load for imediatamente seguido por um desvio condicional que está no resultado do load, dois ciclos de stall serão necessários, pois o resultado do load aparece no final do ciclo MEM, mas é necessário no início do ID do desvio. Apesar dessas dificuldades, mover a execução do desvio para o estágio ID é uma melhoria, pois reduz a penalidade de um desvio a apenas uma instrução se o desvio for tomado, a saber, aquela sendo buscada atualmente. Os exercícios exploram os detalhes da implementação do caminho de forwarding e a detecção do hazard. Para fazer um flush das instruções no estágio IF, acrescentamos uma linha de controle, chamada IF.Flush, que zera o campo de instrução do registrador de pipeline IF/ID. Apagar o registrador transforma a instrução buscada em um nop, uma instrução que não possui ação e não muda estado algum.
Desvios no pipeline Exemplo Mostre o que acontece quando o desvio é tomado nesta sequência de instruções, considerando que o pipeline está otimizado para desvios que não são tomados e que movemos a execução do desvio para o estágio ID:
Resposta A Figura 4.62 mostra o que acontece quando um desvio é tomado. Diferente da Figura 4.61, há somente uma bolha no pipeline para o desvio tomado.
FIGURA 4.62 O estágio ID do ciclo de clock 3 determina que um desvio precisa ser tomado, de modo que seleciona 72 como próximo endereço do PC e zera a instrução buscada para o próximo ciclo de clock. O ciclo de clock 4 mostra a instrução no local 72 sendo buscada e a única bolha ou instrução nop no pipeline como
resultado do desvio tomado. (Como o nop na realidade é s11 $0,$0,0, é discutível se o estágio ID no clock 4 deve ou não ser destacado.)
Previsão dinâmica de desvios Supor que um desvio não seja tomado é uma forma simples de previsão de desvios. Nesse caso, prevemos que os desvios não são tomados, fazendo um flush no pipeline quando estivermos errados. Para o pipeline simples, com cinco estágios, essa técnica, possivelmente acoplada com a previsão baseada no compilador, deverá ser adequada. Com pipelines mais profundos, a penalidade do desvio aumenta quando medida em ciclos de clock. Da mesma forma, com a questão múltipla (Seção 4.10), a penalidade do desvio aumenta em termos de instruções perdidas. Essa combinação significa que, em um pipeline agressivo, um esquema de previsão estática provavelmente desperdiçará muito desempenho. Como mencionamos na Seção 4.5, com mais hardware, é possível tentar prever o comportamento do desvio durante a execução do programa. Uma técnica é pesquisar o endereço da instrução para ver se um desvio foi tomado na última vez que essa instrução foi executada e, se foi, começar a buscar novas instruções a partir do mesmo lugar da última vez. Essa técnica é chamada previsão dinâmica de desvios.
previsão dinâmica de desvios Previsão de desvios durante a execução, usando informações em tempo de execução. Uma implementação dessa técnica é um buffer de previsão de desvios ou tabela de histórico de desvios. Um buffer de previsão de desvios é uma pequena memória indexada pela parte menos significativa do endereço da instrução de desvio. A memória contém um bit que diz se o desvio foi tomado recentemente ou não.
buffer de previsão de desvios Também chamado tabela de histórico de desvios. Uma pequena memória indexada pela parte menos significativa do endereço da instrução de desvio e que contém um ou mais bits indicando se o desvio foi tomado recentemente ou não.
Esse é o tipo de buffer mais simples; na verdade, não sabemos se a previsão é a correta — ela pode ter sido colocada lá por outro desvio, que tem os mesmos bits de endereço menos significativos. Mas isso não afeta a exatidão. A previsão é apenas um palpite considerado correto, de modo que a busca começa na direção prevista. Se o palpite estiver errado, as instruções previstas incorretamente são excluídas, o bit de previsão é invertido e armazenado de volta, e a sequência apropriada é buscada e executada. Esse esquema simples de previsão de 1 bit tem um problema de desempenho: mesmo que um desvio quase sempre seja tomado, provavelmente faremos uma previsão incorreta duas vezes, em vez de uma, quando ele não for tomado. O exemplo a seguir mostra esse dilema.
Loops e previsão Exemplo Considere um desvio de loop que se desvia nove vezes seguidas, depois não é tomado uma vez. Qual é a exatidão da previsão para esse desvio, supondo que o bit de previsão para o desvio permaneça no buffer de previsão?
Resposta O comportamento da previsão de estado fixo fará uma previsão errada na primeira e última iterações do loop. O erro de previsão na última iteração é inevitável, pois o bit de previsão dirá “tomado”, já que o desvio foi tomado nove vezes seguidas nesse ponto. O erro de previsão na primeira iteração acontece porque o bit é invertido na execução anterior da última iteração do loop, pois o desvio não foi tomado nessa iteração final. Assim, a exatidão da previsão para esse desvio tomado 90% do tempo é apenas de 80% (duas previsões incorretas contra oito corretas). O ideal é que a previsão do sistema combine com a frequência de desvio tomado para esses desvios altamente regulares. Para remediar esse ponto fraco, os esquemas de previsão de 2 bits são utilizados com frequência. Em um esquema de 2 bits, uma previsão precisa estar errada duas vezes antes de ser alterada. A Figura 4.63 mostra a máquina de estados finitos para um esquema de previsão de 2 bits.
FIGURA 4.63 Os estados em um esquema de previsão de 2 bits. Usando 2 bits em vez de 1, um desvio que favoreça bastante a situação “tomado” ou “não tomado” — como muitos desvios fazem — será previsto incorretamente apenas uma vez. Os 2 bits são usados para codificar os quatro estados no sistema. O esquema de 2 bits é um caso geral de uma previsão baseada em contador, incrementado quando a previsão é exata e decrementado em caso contrário, utilizando o ponto intermediário desse intervalo como divisão entre desvio tomado e não tomado.
Um buffer de previsão de desvio pode ser implementado como um pequeno buffer especial, acessado com o endereço da instrução durante o estágio do pipe IF. Se a instrução for prevista como tomada, a busca começa a partir do destino assim que o PC for conhecido; conforme mencionamos anteriormente, isso pode ser até mesmo no estágio ID. Caso contrário, a busca e a execução sequencial continuam. Se a previsão for errada, os bits de previsão são trocados, como mostra a Figura 4.63.
Detalhamento Conforme descrevemos na Seção 4.5, em um pipeline de cinco estágios, podemos tornar o hazard de controle em um recurso, redefinindo o desvio.
Um delayed branch sempre executa a seguinte instrução, mas a segunda instrução após o desvio será afetada pelo desvio. Os compiladores e os montadores tentam colocar uma instrução que sempre executa após o desvio no delay slot do desvio. A tarefa do software é tornar as instruções sucessoras válidas e úteis. A Figura 4.64 mostra as três maneiras como o delay slot do desvio pode ser escalonado.
FIGURA 4.64 Escalonando o delay slot do desvio. Em cada par de quadros, o quadro de cima mostra o código antes do escalonamento; o quadro de baixo mostra o código escalonado. Em (a), o delay slot é escalonado com uma instrução independente de antes do desvio. Essa é a melhor opção. As estratégias (b) e (c) são usadas quando (a) não é possível. Nas sequências de código para (b) e (c), o uso de $s1 na condição de desvio impede que a instrução add (cujo destino é $s1) seja movida para o delay slot do desvio. Em (b), o delay slot de desvio é escalonado a partir do destino do desvio; normalmente, a instrução de destino precisará ser copiada, pois pode ser alcançada por outro caminho. A
estratégia (b) é preferida quando o desvio é tomado com alta probabilidade, como em um desvio de loop. Finalmente, o desvio pode ser escalonado a partir da sequência não tomada, como em (c). Para tornar essa otimização válida para (b) ou (c), deve ser válido executar a instrução sub quando o desvio seguir na direção inesperada. Com “válido”, queremos dizer que o trabalho é desperdiçado, mas o programa ainda será executado corretamente. Esse é o caso, por exemplo, se $t4 fosse um registrador temporário não utilizado quando o desvio entrasse na direção inesperada.
As limitações sobre o escalonamento com delayed branch surgem de (1) as restrições sobre as instruções escalonadas nos delay slots e (2) nossa capacidade de prever durante a compilação se um desvio provavelmente será tomado ou não. O delayed branch foi uma solução simples e eficaz para um pipeline de cinco estágios despachando uma instrução a cada ciclo de clock. À medida que os processadores utilizam pipelines maiores, despachando múltiplas instruções por ciclo de clock (Seção 4.10), o atraso do desvio torna-se maior e um único delay slot é insuficiente. Logo, o delayed branch perdeu popularidade em comparação com as técnicas dinâmicas mais dispendiosas, porém mais flexíveis. Simultaneamente, o crescimento em transistores disponíveis por chip, devido à Lei de Moore, tornou a previsão dinâmica relativamente mais barata.
delay slot do desvio O slot diretamente após a instrução de delayed branch, que na arquitetura MIPS é preenchido por uma instrução que não afeta o desvio.
Detalhamento Um previsor de desvios nos diz se um desvio é tomado ou não, mas ainda exige o cálculo do destino do desvio. No pipeline de cinco estágios, esse cálculo leva um ciclo, significando que os desvios tomados terão uma penalidade de um ciclo. Os delayed branches são uma técnica para eliminar essa penalidade. Outra técnica é usar uma cache para manter o contador de programa de destino ou instrução de destino, usando um buffer de destino de desvios. O esquema de previsão dinâmica de 2 bits usa apenas informações sobre um determinado desvio. Os pesquisadores notaram que o uso de informações sobre um desvio local e um comportamento global de desvios executados recentemente, juntos, geram maior exatidão da previsão para o mesmo número de bits de previsão. Essas técnicas são chamadas de previsor correlato. Um previsor correlato simples poderia ter dois previsores de 2 bits para cada desvio, com a escolha entre os previsores feita com base em se o último desvio executado foi tomado ou não. Assim, o comportamento de desvio global pode ser imaginado como acrescentando bits de índice adicionais para a previsão. Uma inovação mais recente na previsão de desvios é o uso de previsões de torneio. Um previsor de torneio utiliza vários previsores, rastreando, para
cada desvio, qual previsor gera os melhores resultados. Um previsor de torneio típico poderia conter duas previsões para cada índice de desvio: uma baseada em informações locais e uma baseada no comportamento do desvio global. Um seletor escolheria qual previsor usar para qualquer previsão dada. O seletor pode operar semelhantemente a um previsor de 1 ou 2 bits, favorecendo qualquer um dos dois previsores que tenha sido mais preciso. Muitos microprocessadores avançados mais recentes utilizam esses previsores rebuscados.
buffer de destino de desvios Uma estrutura que coloca em cache o PC de destino ou a instrução de destino para um desvio. Ele normalmente é organizado como uma cache com tags, tornando-o mais dispendioso do que um buffer de previsão simples.
previsor correlato Um previsor de desvio que combina o comportamento local de determinado desvio e informações globais sobre o comportamento de algum número recente de desvios executados.
previsor de desvio de torneio Um previsor de desvios com múltiplas previsões para cada desvio e um mecanismo de seleção que escolhe qual previsor deve ser usado para determinado desvio
Detalhamento Uma maneira de reduzir o número de desvios condicionais é acrescentar instruções de move condicional. Em vez de mudar o PC com um desvio condicional, a instrução muda condicionalmente o registrador de destino do move. Se a condição falha, o move atua como um nop. Por exemplo, uma versão da arquitetura do conjunto de instruções MIPS tem duas novas instruções chamadas movn (move if not zero) e movz (move if zero). Assim, movn $8,$11,$4 copia o conteúdo do registrador 11 para o registrador 8, desde que o valor no registrador 4 seja diferente de zero; caso contrário, ela não faz nada.
O conjunto de instruções ARMv7 tem um campo de condição na maioria das instruções. Assim, os programas ARM poderiam ter menos desvios condicionais que os programas MIPS.
Resumo sobre pipeline Começamos na lavanderia, mostrando princípios de pipelining em um ambiente cotidiano. Usando essa analogia como um guia, explicamos o pipelining de instruções passo a passo, começando com um caminho de dados de ciclo único e depois acrescentando registradores de pipeline, caminhos de forwarding, detecção de hazard de dados, previsão de desvio e com flushing de instruções em exceções. A Figura 4.65 mostra o caminho de dados e controle finais. Agora, estamos prontos para outro hazard de controle: a questão complicada das exceções.
FIGURA 4.65 O caminho de dados e controle final para este capítulo. Observe que essa é uma figura estilizada, em vez de um caminho de dados detalhado, de modo que não contém o mux ALUScr da Figura 4.57 e os controles multiplexadores da Figura 4.51.
Verifique você mesmo Considere três esquemas de previsão de desvios: desvio não tomado, previsão tomada e previsão dinâmica. Suponha que todos eles tenham penalidade zero quando preveem corretamente e 2 ciclos quando estão errados. Suponha que a exatidão média da previsão do previsor dinâmico seja de 90%. Qual previsor é a melhor escolha para os seguintes desvios? 1. Um desvio tomado com frequência de 5%. 2. Um desvio tomado com frequência de 95%. 3. Um desvio tomado com frequência de 70%.
4.9. Exceções Fazer um computador com facilidades automáticas de interrupção de programa se comportar [sequencialmente] não foi uma tarefa fácil, pois o número de instruções em diversos estágios do processamento, quando ocorre um sinal de interrupção, pode ser muito grande. Fred Brooks Jr., Planning a Computer System: Project Stretch, 1962
Controle é o aspecto mais desafiador do projeto do processador: ele é a parte mais difícil de se acertar e a parte mais difícil de tornar mais rápida. Uma das partes mais difíceis do controle é implementar exceções e interrupções — eventos diferentes dos desvios ou saltos, que mudam o fluxo normal da execução da instrução. Eles foram criados inicialmente para tratar de eventos inesperados de dentro do processador, como o overflow aritmético. O mesmo mecanismo básico foi estendido para os dispositivos de E/S se comunicarem com o processador, conforme veremos no Capítulo 5.
exceção Também chamada interrupção. Um evento não programado que interrompe a execução do programa; usada para detectar overflow.
interrupção Uma exceção que vem de fora do processador. (Algumas arquiteturas utilizam
o termo interrupção para todas as exceções.) Muitas arquiteturas e autores não fazem distinção entre interrupções e exceções, normalmente usando o nome mais antigo interrupção para se referirem aos dois tipos de eventos. Por exemplo, o Intel x86 usa interrupção. Seguimos a convenção do MIPS, usando o termo exceção para indicar qualquer mudança inesperada no fluxo de controle, sem distinguir se a causa é interna ou externa; usamos o termo interrupção apenas quando o evento é causado externamente. Aqui estão alguns exemplos mostrando se a situação é gerada internamente pelo processador ou se é gerada externamente: Tipo de evento Solicitação de dispositivo de E/S
De onde?
Terminologia MIPS
Externa
Interrupção
Chamar o sistema operacional do programa do usuário Interna
Exceção
Overflow aritmético
Interna
Exceção
Usar uma instrução indefinida
Interna
Exceção
Defeitos do hardware
Ambos
Exceção ou interrupção
Muitos dos requisitos para dar suporte a exceções vêm da situação específica que causa a ocorrência de uma exceção. Consequentemente, retornaremos a esse assunto no Capítulo 5, quando entenderemos melhor a motivação para as capacidades adicionais no mecanismo de exceção. Nesta seção, lidamos com a implementação de controle de modo a detectar dois tipos de exceções que surgem das partes do conjunto de instruções e da implementação que já discutimos. Detectar condições excepcionais e tomar a ação apropriada normalmente está no percurso de temporização crítico de um processador, que determina o tempo de ciclo de clock e, portanto, o desempenho. Sem a devida atenção às exceções durante o projeto da unidade de controle, as tentativas de acrescentar exceções a uma implementação complicada podem reduzir o desempenho significativamente, bem como complicar a tarefa de corrigir o projeto.
Como as exceções são tratadas em uma arquitetura MIPS Os dois tipos de exceções que nossa implementação atual pode gerar são: a execução de uma instrução indefinida e um overflow aritmético. Usaremos o
overflow aritmético na instrução add $1,$2,$1 como exemplo de exceção nas próximas páginas. A ação básica que o processador deve realizar quando ocorre uma exceção é salvar o endereço da instrução causadora no contador de programa de exceção (Exception Program Counter — EPC) e depois transferir o controle para o sistema operacional em algum endereço especificado. O sistema operacional pode então tomar a ação apropriada, que pode ser fornecer algum serviço ao programa do usuário, tomar alguma ação predefinida em resposta a um overflow ou terminar a execução do programa e informar um erro. Depois de realizar qualquer ação necessária devido à exceção, o sistema operacional pode terminar o programa ou pode continuar sua execução, usando o EPC para determinar onde reiniciar a execução do programa. No Capítulo 5, veremos mais de perto a questão da retomada da execução. Para o sistema operacional tratar da exceção, ele precisa conhecer o motivo da exceção, além da instrução que a causou. Existem dois métodos principais usados para comunicar o motivo de uma exceção. O método da arquitetura MIPS inclui um registrador de status (chamado registrador Cause), que mantém um campo que indica o motivo da exceção. Um segundo método usa interrupções vetorizadas. Em uma interrupção vetorizada, o endereço ao qual o controle é transferido é determinado pela causa da exceção. Por exemplo, para acomodar os dois tipos de exceção listados anteriormente, poderíamos definir os dois endereços de vetor de exceção a seguir:
interrupção vetorizada Uma interrupção para a qual o endereço para onde o controle é transferido é determinado pela causa da exceção. Tipo de exceção
Endereço do vetor de exceção (em hexa)
Instrução indefinida
8000 0000hexa
Overflow aritmético
8000 0180hexa
O sistema operacional sabe o motivo para a exceção pelo endereço em que ela é iniciada. Os endereços são separados por 32 bytes ou oito instruções, e o sistema operacional precisa registrar o motivo para a exceção e pode realizar algum processamento limitado nessa sequência. Quando a exceção não é vetorizada, um único ponto de entrada para todas as exceções pode ser utilizado,
e o sistema operacional decodifica o registrador de status para encontrar a causa. Podemos realizar o processamento exigido para exceções acrescentando alguns registradores e sinais de controle extras à nossa implementação básica e estendendo o controle ligeiramente. Vamos supor que estejamos implementando o sistema de exceção utilizado na arquitetura MIPS, com o único ponto de entrada sendo o endereço 8000 0180hexa. (A implementação de exceções vetorizadas não é mais difícil.) Precisaremos acrescentar dois registradores adicionais à nossa implementação MIPS atual: ▪ EPC: Um registrador de 32 bits usado para manter o endereço da instrução afetada. (Esse registrador é necessário mesmo quando as exceções são vetorizadas.) ▪ Cause: Um registrador usado para registrar a causa da exceção. Na arquitetura MIPS, esse registrador tem 32 bits, embora alguns bits atualmente não sejam utilizados. Suponha que haja um campo de cinco bits que codifica as duas fontes de informação possíveis mencionadas anteriormente, com 10 representando uma instrução indefinida e 12 representando o overflow aritmético.
Exceções em uma implementação em pipeline Uma implementação em pipeline trata exceções como outra forma de hazard de controle. Por exemplo, suponha que haja um overflow aritmético em uma instrução add. Assim como fizemos para o desvio tomado na seção anterior, temos de dar flush nas instruções que vêm após a instrução add do pipeline e começar a buscar instruções do novo endereço. Usaremos o mesmo mecanismo que usamos para os desvios tomados, mas, desta vez, a exceção causa a desativação das linhas de controle. Quando lidamos com um desvio mal previsto, vimos como dar flush na instrução no estágio IF, transformando-a em um nop. Para dar flush nas instruções no estágio ID, usamos o multiplexador já presente no estágio ID que zera os sinais de controle para stalls. Um novo sinal de controle, chamado ID.Flush, realiza um OR com o sinal de stall da unidade de detecção de hazards, a fim de dar flush durante o ID. Para dar flush na instrução na fase EX, usamos um novo sinal, chamado EX.Flush, fazendo com que novos multiplexadores zerem as linhas de controle. Para começar a buscar instruções do local 8000 0180hexa, que é o local da exceção para o overflow aritmético, simplesmente acrescentamos uma entrada adicional ao multiplexador do PC, que envia 8000
0180hexa ao PC. A Figura 4.66 mostra essas mudanças.
FIGURA 4.66 O caminho de dados com controles para lidar com exceções. Os principais acréscimos incluem uma nova entrada, com o valor 8000 0180hexa, no multiplexador que fornece o novo valor do PC; um registrador Cause para registrar a causa da exceção; e um registrador PC de Exceção (Exception Program Counter — EPC) para salvar o endereço da instrução que causou a exceção. A entrada 8000 0180hexa para o multiplexador é o endereço inicial para começar a buscar instruções no caso de uma exceção. Embora não apareça, o sinal de overflow da ALU é uma entrada para a unidade de controle.
Este exemplo aponta um problema com as exceções: se não pararmos a execução no meio da instrução, o programador não poderá ver o valor original do registrador $1 que ajudou a causar o overflow, pois funcionará como registrador de destino da instrução add. Devido ao planejamento cuidadoso, a exceção de overflow é detectada durante o estágio EX; logo, podemos usar o sinal EX.Flush para impedir que a instrução no estágio EX escreva seu resultado no estágio WB. Muitas exceções exigem que, por fim, completemos a instrução que causou a exceção como se ela fosse executada normalmente. O modo mais
fácil de fazer isso é dar flush na instrução e reiniciá-la desde o início após a exceção ser tratada. A etapa final é salvar o endereço da instrução problemática no Exception Program Counter (EPC). Na realidade, salvamos o endereço +4, de modo que a rotina de tratamento da exceção, primeiro deve subtrair 4 do valor salvo. A Figura 4.66 mostra uma versão estilizada do caminho de dados, incluindo o hardware de desvio e as acomodações necessárias para tratar das exceções.
Exceção em um computador com pipeline Exemplo Dada esta sequência de instruções
considere que as instruções a serem invocadas em uma exceção comecem desta forma:
Mostre o que acontece no pipeline se houver uma exceção de overflow na instrução add.
Resposta A Figura 4.67 mostra os eventos, começando com a instrução add no estágio EX. O overflow é detectado durante essa fase, e 8000 0180hexa é forçado para o PC. O ciclo de clock 7 mostra que o add e as instruções seguintes sofrem flush, e a primeira instrução do código de exceção é buscada. Observe que o endereço da instrução seguinte ao add é salvo: 4Chexa + 4 = 50hexa.
FIGURA 4.67 O resultado de uma exceção devido a um overflow aritmético na instrução add. O overflow é detectado durante o estágio EX do clock 6, salvando o endereço após o add no registrador EPC (4C + 4 = 50hexa). O overflow faz com que todos os sinais Flush sejam ativados perto do final desse ciclo de clock, desativando os valores de controle (colocando-os em 0) para o add. O ciclo de clock 7 mostra as instruções convertidas para bolhas no
pipeline, mais a busca da primeira instrução da rotina de exceção — sw $25,1000($0) — a partir do local da instrução 8000 0180hexa. Observe que as instruções AND e OR, que estão antes do add, ainda completam. Embora não apareça, o sinal de overflow da ALU é uma entrada para a unidade de controle.
Mencionamos cinco exemplos de exceções na tabela da Seção 4.9, e veremos outros no Capítulo 5. Com cinco instruções ativas em qualquer ciclo de clock, o desafio é associar uma exceção à instrução apropriada. Além do mais, várias exceções podem ocorrer simultaneamente em um único ciclo de clock. A solução é priorizar as exceções de modo que seja fácil determinar qual será atendida primeiro. Na maioria das implementações MIPS, o hardware ordena as exceções de modo que a instrução mais antiga seja interrompida. Solicitações de dispositivos de E/S e defeitos do hardware não estão associados a uma instrução específica, de modo que a implementação possui alguma flexibilidade quanto ao momento de interromper o pipeline. Logo, usar o mecanismo utilizado para outras exceções funciona muito bem. O EPC captura o endereço das instruções interrompidas, e o registrador Cause do MIPS registra todas as exceções possíveis em um ciclo de clock, de modo que o software de exceção precisa combinar a exceção à instrução. Uma dica importante é saber em que estágio do pipeline um tipo de exceção pode ocorrer. Por exemplo, uma instrução indefinida é descoberta no estágio ID, e a chamada ao sistema operacional ocorre no estágio EX. As exceções são coletadas no registrador Cause em um campo de exceção pendente, de modo que o hardware possa interromper com base em exceções posteriores, uma vez que a mais antiga tenha sido atendida.
Interface hardware/software O hardware e o sistema operacional precisam trabalhar em conjunto para que as exceções se comportem conforme o esperado. O contrato do hardware normalmente é interromper a instrução problemática no meio do caminho, deixar que todas as instruções anteriores terminem, dar flush em todas as instruções seguintes, definir um registrador para mostrar a causa da exceção, salvar o endereço da instrução problemática e depois desviar para um endereço previamente arranjado. O contrato do sistema operacional é
examinar a causa da exceção e atuar de forma apropriada. Para uma instrução indefinida, falha de hardware ou exceção por overflow aritmético, o sistema operacional normalmente encerra o programa e retorna um indicador do motivo. Para uma solicitação de dispositivo de E/S ou uma chamada de serviço ao sistema operacional, o próprio sistema salva o estado do programa, realiza a tarefa desejada e, em algum ponto no futuro, restaura o programa para continuar a execução. No caso das solicitações do dispositivo de E/S, normalmente podemos escolher executar outra tarefa antes de retomar a tarefa que requisitou a E/S, pois essa tarefa em geral pode não ser capaz de prosseguir até que a E/S termine. É por isso que é fundamental a capacidade de salvar e restaurar o estado de qualquer tarefa. Um dos usos mais importantes e frequentes das exceções é o tratamento de faltas de página e exceções de TLB; o Capítulo 5 descreve essas exceções e seu tratamento com mais detalhes.
Detalhamento A dificuldade de sempre associar a exceção correta à instrução correta nos computadores em pipeline levou alguns projetistas de computador a relaxarem esse requisito em casos não críticos. Alguns processadores são considerados como tendo interrupções imprecisas ou exceções imprecisas. No exemplo anterior, o PC normalmente teria 58hexa no início do ciclo de clock, depois que a exceção for detectada, embora a instrução com problema esteja no endereço 4Chexa. Um processador com exceções imprecisas poderia colocar 58hexa no EPC e deixar que o sistema operacional determinasse qual instrução causou o problema. O MIPS e a grande maioria dos computadores de hoje admitem interrupções precisas ou exceções precisas. (Um motivo é para dar suporte à memória virtual, que veremos no Capítulo 5.)
interrupção imprecisa Também chamada exceção imprecisa. As interrupções ou exceções nos computadores em pipeline não estão associadas à instrução exata que foi a causa da interrupção ou exceção.
interrupção precisa
Também chamada exceção precisa. Uma interrupção ou exceção que está sempre associada à instrução correta nos computadores em pipeline.
Detalhamento Embora o MIPS utilize o endereço de entrada de exceção 8000 0180hexa para quase todas as exceções, ele usa o endereço 8000 0000hexa de modo a melhorar o desempenho do tratador de exceção para exceções de falta de TLB (Capítulo 5).
Verifique você mesmo Qual exceção deverá ser reconhecida primeiro nesta sequência?
4.10. Paralelismo e paralelismo avançado em nível de instrução Esteja avisado de que esta seção é uma breve introdução de assuntos fascinantes, porém avançados. Se você quiser saber mais detalhes, deverá consultar nosso livro mais avançado, Computer Architecture: A Quantitative Approach, 5ª edição (Morgan Kaufmann, 2012), quarta edição, no qual o material explicado nas próximas páginas é expandido para mais de 200 páginas (incluindo Apêndices)! A técnica de pipelining explora o paralelismo em potencial entre as instruções. Esse paralelismo é chamado de paralelismo em nível de instrução (ILP — Instruction-Level Parallelism). Existem dois métodos principais para aumentar a quantidade em potencial de paralelismo em nível de instrução. O primeiro é aumentar a profundidade do pipeline para sobrepor mais instruções. Usando nossa analogia da lavanderia e considerando que o ciclo da lavadora
fosse maior do que os outros, poderíamos dividir nossa lavadora em três máquinas que lavam, enxaguam e centrifugam, como as etapas de uma lavadora tradicional. Poderíamos, então, passar de um pipeline de quatro para seis estágios. Para ganhar o máximo de velocidade, precisamos rebalancear as etapas restantes de modo que tenham o mesmo tamanho, nos processadores ou na lavanderia. A quantidade de paralelismo sendo explorada é maior, pois existem mais operações sendo sobrepostas. O desempenho é potencialmente maior, pois o ciclo de clock pode ser encurtado.
paralelismo em nível de instrução O paralelismo entre as instruções. Outra técnica é replicar os componentes internos do computador de modo que ele possa iniciar várias instruções em cada estágio do pipeline. O nome geral para essa técnica é despacho múltiplo. Uma lavanderia com despacho múltiplo substituiria nossa lavadora e secadora doméstica por, digamos, três lavadoras e três secadoras. Você também teria de recrutar mais auxiliares para passar e guardar três vezes a quantidade de roupas no mesmo período. A desvantagem é o trabalho extra de manter todas as máquinas ocupadas e transferir as trouxas de roupa para o próximo estágio do pipeline.
despacho múltiplo Um esquema pelo qual múltiplas instruções são disparadas em 1 ciclo de clock. Disparar várias instruções por estágio permite que a velocidade de execução da instrução exceda a velocidade de clock ou, de forma alternativa, que o CPI seja menor do que 1. Como dissemos no Capítulo 1, às vezes, é útil inverter a
métrica e usar o IPC ou instruções por ciclo de clock. Logo, um microprocessador de despacho múltiplo quádruplo de 4 GHz pode executar uma velocidade de pico de 16 bilhões de instruções por segundo e ter um CPI de 0,25 no melhor dos casos ou um IPC de 4. Considerando um pipeline de cinco estágios, esse processador teria 20 instruções executando em determinado momento. Os microprocessadores mais potentes de hoje tentam despachar de três a oito instruções a cada ciclo de clock. Entretanto, normalmente existem muitas restrições sobre os tipos das instruções que podem ser executadas simultaneamente e o que acontece quando surgem dependências. Existem duas maneiras importantes de implementar um processador de despacho múltiplo, sendo que a principal diferença está na divisão de trabalho entre o compilador e o hardware. Como a divisão do trabalho indica se as decisões estão sendo feitas estaticamente (ou seja, durante a compilação) ou dinamicamente (ou seja, durante a execução), as técnicas, às vezes, são chamadas de despacho múltiplo estático e despacho múltiplo dinâmico. Como veremos, as duas técnicas possuem outros nomes, usados mais comumente, que podem ser menos precisos ou mais restritivos.
despacho múltiplo estático Uma técnica para implementar um processador de despacho múltiplo em que muitas decisões são tomadas pelo compilador antes da execução.
despacho múltiplo dinâmico Uma técnica para implementar um processador de despacho múltiplo em que muitas decisões são tomadas pelo processador durante a execução. Existem duas responsabilidades principais e distintas que precisam ser tratadas em um pipeline de despacho múltiplo: 1. Empacotar as instruções em slots de despacho: como o processador determina quantas e quais instruções podem ser despachadas em determinado ciclo de clock? Na maioria dos processadores de despacho estático, esse processo é tratado, pelo menos, parcialmente pelo compilador; nos projetos de despacho dinâmico, isso normalmente é tratado durante a execução pelo processador, embora o compilador em geral já tenha tentado ajudar a melhorar a velocidade do despacho colocando as instruções em uma ordem benéfica.
2. Lidar com hazards de dados e de controle: em processadores de despacho estático, algumas ou todas as consequências dos hazards de dados e controle são tratadas estaticamente pelo compilador. Ao contrário, a maioria dos processadores de despacho dinâmico tenta aliviar pelo menos algumas classes de hazards usando técnicas de hardware operando durante a execução.
slots de despacho As posições das quais as instruções poderiam ser despachadas em determinado ciclo de clock; por analogia, correspondem a posições nos blocos iniciais para uma atividade. Embora as tenhamos descrito como técnicas distintas, na realidade, cada técnica pega algo emprestado da outra e nenhuma pode afirmar ser perfeitamente pura.
O conceito de especulação Um dos métodos mais importantes para localizar e explorar mais ILP é a especulação. Com base na grande ideia da predição, especulação é uma técnica que permite que o compilador ou o processador “adivinhem” as propriedades de uma instrução, de modo a permitir que a execução comece para outras instruções que possam depender da instrução especulada. Por exemplo, poderíamos especular a respeito do resultado de um desvio, de modo que as instruções após o desvio pudessem ser executadas mais cedo. Outro exemplo é que poderíamos especular que um store que precede um load não se refere ao mesmo endereço, o que permitiria que o load fosse executado antes do store. A dificuldade com a especulação é que ela pode estar errada. Assim, qualquer mecanismo de especulação deve incluir tanto um método para verificar se a escolha foi certa quanto um método para retornar ou retroceder os efeitos das instruções executadas de forma especulativa. A implementação dessa capacidade de retrocesso aumenta a complexidade.
especulação Uma técnica pela qual o compilador ou processador adivinha o resultado de uma instrução para removê-la como uma dependência na execução de outras instruções. A especulação pode ser feita pelo compilador ou pelo hardware. Por exemplo, o compilador pode usar a especulação para reordenar as instruções, fazendo uma instrução passar por um desvio ou um load passar por um store. O hardware do processador pode realizar a mesma transformação durante a execução, usando técnicas que discutiremos mais adiante nesta seção. Os mecanismos de recuperação usados para a especulação incorreta são bem diferentes. No caso da especulação em software, o compilador normalmente insere instruções adicionais que verificam a precisão da especulação e oferecem uma rotina de reparo para usar quando a especulação tiver sido incorreta. Na especulação em hardware, o processador normalmente coloca os resultados
especulativos em um buffer até que saiba que não são mais especulativos. Se a especulação estiver correta, as instruções são concluídas, permitindo que o conteúdo dos buffers seja escrito nos registradores ou na memória. Se a especulação estiver incorreta, o hardware faz um flush nos buffers e executa novamente, mas na sequência de instruções correta. A especulação apresenta outro problema possível: especular sobre certas instruções pode gerar exceções que, anteriormente, não estavam presentes. Por exemplo, suponha que uma instrução load seja movida de uma maneira especulativa, mas o endereço que usa não é válido quando a especulação for incorreta. O resultado é que ocorrerá uma exceção que não deveria ter ocorrido. O problema é complicado pelo fato de que, se a instrução load não fosse especulativa, então, a exceção deveria ocorrer! Na especulação feita pelo compilador, esses problemas são evitados pelo acréscimo de suporte especial à especulação, que permite que tais exceções sejam ignoradas, até que esteja claro que elas realmente devam ocorrer. Na especulação por hardware, as exceções são simplesmente mantidas em um buffer até que fique claro que a instrução que as causam não é mais especulativa e está pronta para terminar; nesse ponto, a exceção é gerada, e prossegue o tratamento normal da exceção. Como a especulação pode melhorar o desempenho quando realizada corretamente e diminuir o desempenho quando feita descuidadamente, é preciso haver muito esforço na decisão de quando a especulação é apropriada. Mais adiante, nesta seção, vamos examinar as técnicas estática e dinâmica para a especulação.
Despacho múltiplo estático Todos os processadores de despacho múltiplo estático utilizam o compilador para ajudar no empacotamento de instruções e no tratamento de hazards. Em um processador de despacho estático, você pode pensar no conjunto de instruções despachadas em determinado ciclo de clock, o que é chamado pacote de despacho, como uma grande instrução com várias operações. Essa visão é mais do que uma analogia. Como um processador de despacho múltiplo estático normalmente restringe o mix de instruções que podem ser iniciadas em determinado ciclo de clock, é útil pensar no pacote de despacho como uma única instrução, permitindo várias operações em certos campos predefinidos. Essa visão levou ao nome original para essa técnica: VLIW (Very Long Instruction Word — palavra de instrução muito longa).
pacote de despacho O conjunto de instruções despachadas juntas em um ciclo de clock; o pacote pode ser determinado estaticamente, pelo compilador, ou dinamicamente, pelo processador.
VLIW (Very Long Instruction Word) Um estilo de arquitetura de conjunto de instruções que dispara muitas operações definidas para serem independentes em uma única instrução larga, normalmente com muitos campos de opcode separados. A maioria dos processadores de despacho estático também conta com o compilador para assumir alguma responsabilidade por tratar de hazards de dados e controle. As responsabilidades do compilador podem incluir previsão estática de desvios e escalonamento de código, para reduzir ou impedir todos os hazards. Vejamos uma versão simples do despacho estático de um processador MIPS, antes de descrevermos o uso dessas técnicas em processadores mais agressivos. Um exemplo: despacho múltiplo estático com a ISA do MIPS Para que você tenha uma ideia do despacho múltiplo estático, consideramos um processador MIPS simples capaz de despachar duas instruções por ciclo, sendo que uma das instruções pode ser uma operação da ALU com inteiros e a outra pode ser um load ou um store. Esse projeto é como aquele utilizado em alguns processadores MIPS embutidos. O despacho de duas instruções por ciclo exigirá a busca e a decodificação de 64 bits de instruções. Em muitos processadores de despacho múltiplo, e basicamente em todos os processadores VLIW, o layout do despacho de instruções simultâneas é restrito para simplificar a decodificação e o despacho da instrução. Logo, exigiremos que as instruções sejam emparelhadas e alinhadas em um limite de 64 bits, com a parte da ALU ou desvio aparecendo primeiro. Além do mais, se uma instrução do par não puder ser usada, exigimos que ela seja substituída por um nop. Assim, as instruções sempre são despachadas em pares, possivelmente com um nop em um slot. A Figura 4.68 mostra como as instruções aparecem enquanto entram no pipeline em pares.
FIGURA 4.68 Pipeline com despacho estático de duas instruções em operação. As instruções da ALU e de transferência de dados são despachadas ao mesmo tempo. Aqui, consideramos a mesma estrutura de cinco estágios utilizada para o pipeline de despacho único. Embora isso não seja estritamente necessário, possui algumas vantagens. Em particular, manter as escritas de registrador no final do pipeline simplifica o tratamento de exceções e a manutenção de um modelo de exceção preciso, que se torna mais difícil em processadores de despacho múltiplo.
Os processadores de despacho múltiplo estático variam no modo como lidam com hazards de dados e controle em potencial. Em alguns projetos, o compilador tem responsabilidade completa por remover todos os hazards, escalonando o código e inserindo no-ops de modo que o código execute sem qualquer necessidade de detecção de hazard ou stalls gerados pelo hardware. Em outros, o hardware detecta os hazards de dados e gera stalls entre dois pacotes de despacho, enquanto exige que o compilador evite todas as dependências dentro de um par de instruções. Mesmo assim, um hazard geralmente força o pacote de despacho inteiro contendo a instrução dependente a sofrer stall. Se o software precisa lidar com todos os hazards ou apenas tentar reduzir a fração de hazards entre pacotes de despacho separados, a aparência de haver uma única grande instrução com várias operações é reforçada. Ainda assumiremos a segunda técnica para esse exemplo. Para emitir uma operação da ALU e uma operação de transferência de dados em paralelo, a primeira necessidade para o hardware adicional — além da lógica normal de detecção de hazard e stall — são portas extras no banco de registradores (Figura 4.69). Em um ciclo de clock, podemos ter de ler dois registradores para a operação da ALU e mais dois para um store, e também uma porta de escrita para uma operação da ALU e uma porta de escrita para um load. Como a ALU está presa à operação da ALU, também precisamos de um
somador separado, a fim de calcular o endereço efetivo para as transferências de dados. Sem esses recursos extras, nosso pipeline com despacho duplo seria atrapalhado pelos hazards estruturais.
FIGURA 4.69 Um caminho de dados com despacho duplo estático. Os acréscimos necessários para o despacho duplo estão destacados: outros 32 bits da memória de instruções, mais duas portas de leitura e mais uma porta de escrita no banco de registradores, e outra ALU. Suponha que a ALU inferior trate dos cálculos de endereço para transferências de dados e a ALU superior trate de todo o restante.
Claramente, esse processador com despacho duplo pode melhorar o desempenho por um fator de até 2. Entretanto, fazer isso exige que o dobro de instruções seja superposto na execução e essa sobreposição adicional aumenta a perda de desempenho relativa aos hazards de dados e controle. Por exemplo, em nosso pipeline simples de cinco estágios, os loads possuem uma latência de uso de um ciclo de clock, o que impede que uma instrução use o resultado sem sofrer stall. No pipeline com despacho duplo e cinco estágios, o resultado de uma instrução load não pode ser usado no próximo ciclo de clock. Isso significa que as duas instruções seguintes não podem usar o resultado do load sem sofrer stall.
Além do mais, as instruções da ALU que não tiveram latência de uso no pipeline simples de cinco estágios, agora possuem uma latência de uso de uma instrução, pois os resultados não podem ser usados no load ou store emparelhados. Para explorar com eficiência o paralelismo disponível em um processador com despacho múltiplo, é preciso utilizar técnicas mais ambiciosas de escalonamento de compilador ou hardware, e o despacho múltiplo estático requer que o compilador assuma essa função.
latência de uso Número de ciclos de clock entre uma instrução load e uma instrução que pode usar o resultado do load sem stall do pipeline.
Escalonamento de código simples para despacho múltiplo Exemplo Como este loop seria escalonado em um pipeline com despacho duplo estático para o MIPS?
Reordene as instruções para evitar o máximo de stalls do pipeline possível. Considere que os desvios são previstos, de modo que os hazards de controle sejam tratados pelo hardware.
Resposta As três primeiras instruções possuem dependências de dados, bem como as duas últimas. A Figura 4.70 mostra o melhor escalonamento para essas instruções. Observe que apenas um par de instruções possui os dois slots utilizados. São necessários quatro clocks por iteração do loop; com quatro
clocks para executar cinco instruções, obtemos o CPI decepcionante de 0,8 versus o melhor caso de 0,5 ou um IPC de 1,25 versus 2,0. Observe que, no cálculo do CPI ou do IPC, não contamos quaisquer nops executados como instruções úteis. Isso melhoraria o CPI, mas não o desempenho!
FIGURA 4.70 O código escalonado conforme apareceria em um pipeline MIPS com despacho duplo. Os slots vazios são nops.
Uma importante técnica de compilador para conseguir mais desempenho dos loops é o desdobramento de loop (loop unrolling), em que são feitas várias cópias do corpo do loop. Após o desdobramento, haverá mais ILP disponível pela sobreposição de instruções de diferentes iterações.
desdobramento de loop (loop unrolling) Uma técnica para conseguir mais desempenho dos loops que acessam arrays, em que são feitas várias cópias do corpo do loop e instruções de diferentes iterações são escalonadas juntas.
Desdobramento de loop para pipelines com despacho múltiplo Exemplo Veja como o trabalho de desdobramento do loop e escalonamento funciona no exemplo anterior. Para simplificar, suponha que o índice do loop seja um múltiplo de quatro.
Resposta Para escalonar o loop sem quaisquer atrasos, acontece que precisamos fazer
quatro cópias do corpo do loop. Depois de desdobrar e eliminar as instruções de overhead de loop desnecessárias, o loop terá quatro cópias de lw, add e sw, mais um addi e um bne. A Figura 4.71 mostra o código desdobrado e escalonado.
FIGURA 4.71 O código desdobrado e escalonado da Figura 4.70 conforme apareceria no pipeline MIPS com despacho duplo estático. Os slots vazios são nops. Como a primeira instrução no loop decrementa $s1 em 16, os endereços lidos são o valor original de $s1, depois esse endereço menos 4, menos 8 e menos 12.
Durante o processo de desdobramento, o compilador introduziu registradores adicionais ($t1, $t2, $t3). O objetivo desse processo, chamado renomeação de registradores, é eliminar dependências que não são dependências de dados verdadeiras, mas que poderiam levar a hazards em potencial ou impedir que o compilador escalonasse o código de forma flexível. Considere como o código não desdobrado apareceria usando apenas $t0. Haveria instâncias repetidas de lw $t0,0($s1), addu $t0,$t0,$s2 seguidas por sw t0,4($s1), mas essas sequências, apesar do uso de $t0, na realidade são completamente independentes — nenhum valor de dados flui entre um par dessas instruções e o par seguinte. É isso que é chamado de antidependência ou dependência de nome, que é uma ordenação forçada puramente pela reutilização de um nome, em vez de uma dependência de dados real, que também é chamada de dependência verdadeira. Renomear os registradores durante o processo de desdobramento permite que o compilador mova subsequentemente essas instruções independentes, de modo a escalonar melhor o código. O processo de renomeação elimina as dependências de nome, enquanto preserva as dependências verdadeiras.
Observe agora que 12 das 14 instruções no loop são executadas como um par. São necessários oito clocks para quatro iterações do loop ou dois clocks por iteração, o que gera um CPI de 8/14 = 0,57. O desdobramento e o escalonamento do loop com despacho dual nos deram um fator de melhoria de quase dois, parcialmente pela redução das instruções de controle de loop e parcialmente pela execução do despacho dual. O custo dessa melhoria de desempenho é usar quatro registradores temporários em vez de um, além de um aumento significativo no tamanho do código.
renomeação de registradores O restante dos registradores é usado, pelo compilador ou hardware, para remover antidependências.
antidependência Também chamada dependência de nome. Uma ordenação forçada pela reutilização de um nome, normalmente um registrador, em vez de uma dependência verdadeira que transporta um valor entre duas instruções.
Processadores com despacho múltiplo dinâmico Os processadores de despacho múltiplo dinâmico também são conhecidos como processadores superescalares ou simplesmente superescalares. Nos processadores superescalares mais simples, as instruções são despachadas em ordem e o processador decide se zero, uma ou mais instruções podem ser despachadas em determinado ciclo de clock. Obviamente, conseguir um bom desempenho em tal processador ainda exige que o compilador tente escalonar instruções para separar as dependências e, com isso, melhorar a velocidade de despacho de instruções. Mesmo com esse escalonamento de compilador, existe uma diferença importante entre essa arquitetura superescalar simples e um processador VLIW: o código, seja ele escalonado ou não, é garantido pelo hardware que será executado corretamente. Além do mais, o código compilado sempre será executado corretamente, independente da velocidade de despacho ou estrutura do pipeline do processador. Em alguns projetos VLIW, isso não tem acontecido, e a recompilação foi necessária quando da mudança por diferentes modelos de processador; em outros processadores de despacho estático, o código seria executado corretamente em diversas implementações, mas constantemente
de uma forma tão pouco eficiente que torna a compilação necessária.
superescalar Uma técnica de pipelining avançada que permite que o processador execute mais de uma instrução por ciclo de clock selecionando-as durante a execução. Muitas arquiteturas superescalares estendem a estrutura básica das decisões de despacho dinâmico para incluir escalonamento dinâmico em pipeline. O escalonamento dinâmico em pipeline escolhe quais instruções serão executadas em determinado ciclo de clock, enquanto tenta evitar hazards e stalls. Vamos começar com um exemplo simples de impedimento de um hazard de dados. Considere a seguinte sequência de código:
escalonamento dinâmico em pipeline Suporte do hardware para modificar a ordem de execução das instruções de modo a evitar stalls. Embora a instrução sub esteja pronta para executar, ela precisa esperar que lw e addu terminem primeiro, o que poderia exigir muitos ciclos de clock se a memória for lenta. (O Capítulo 5 explica as faltas de cache, motivo pelo qual os acessos à memória às vezes são muito lentos.) O escalonamento dinâmico em pipeline permite que tais hazards sejam evitados total ou parcialmente.
Escalonamento dinâmico em pipeline O escalonamento dinâmico em pipeline escolhe quais instruções serão executadas em seguida, possivelmente reordenando-as para evitar stalls. Nestes processadores, o pipeline é dividido em três unidades principais: uma unidade de busca e despacho de instruções, várias unidades funcionais (uma dezena ou mais nos projetos de alto nível em 2013) e uma unidade de commit. A Figura 4.72 mostra o modelo. A primeira unidade busca instruções, decodifica-as e envia cada instrução a uma unidade funcional correspondente para execução. Cada unidade funcional possui buffers, chamados estações de reserva, que mantêm os operandos e a operação. (Na seção de Detalhamento, discutiremos uma alternativa às estações de reserva utilizadas por muitos processadores recentes.) Assim que o buffer tiver todos os seus operandos e a unidade funcional estiver pronta para executar, o resultado será calculado. Quando o resultado for completado, ele será enviado a quaisquer estações de reserva esperando por esse resultado em particular, bem como a unidade de commit, que mantém o resultado em um buffer até que seja seguro colocar o resultado no banco de registradores ou, para um store, na memória. O buffer na unidade de commit, normalmente chamado de buffer de reordenação, também é usado para
fornecer operandos, mais ou menos da mesma maneira como a lógica de forwarding faz em um pipeline escalonado estaticamente. Quando um resultado é submetido ao banco de registradores, ele pode ser apanhado diretamente de lá, como em um pipeline normal.
FIGURA 4.72 As três unidades principais de um pipeline escalonado dinamicamente. A etapa final da atualização do estado também é chamada de reforma ou graduação.
unidade de commit A unidade em um pipeline de execução dinâmica ou fora de ordem que decide quando é seguro liberar o resultado de uma operação aos registradores e memória visíveis ao programador.
estação de reserva Um buffer dentro de uma unidade funcional que mantém os operandos e a operação.
buffer de reordenação O buffer que mantém resultados em um processador escalonado dinamicamente até que seja seguro armazenar os resultados na memória ou em um registrador. A combinação de operandos em buffers nas estações de reserva e os resultados no buffer de reordenação oferecem uma forma de renomeação de registradores, assim como aquela utilizada pelo compilador em nosso exemplo anterior de desdobramento de loop, anteriormente neste capítulo. Para ver como isso funciona conceitualmente, considere as seguintes etapas: 1. Quando uma instrução é despachada, ela é copiada para uma estação de reserva para a unidade funcional apropriada. Quaisquer operandos que estejam disponíveis no banco de registradores ou no buffer de reordenação também serão copiados para a estação de reserva imediatamente. A instrução é mantida em um buffer até que todos os operandos e a unidade funcional estejam disponíveis. Para a instrução despachada, a cópia do registrador operando não é mais necessária, e se houvesse uma escrita nesse registrador, o valor poderia ser reescrito. 2. Se um operando não estiver no banco de registradores ou no buffer de reordenação, ele terá de esperar para ser produzido por uma unidade funcional. O nome da unidade funcional que produzirá o resultado é rastreado. Quando essa unidade por fim produz o resultado, ele é copiado diretamente para a estação de reserva, que estava aguardando, a partir da unidade funcional, sem passar pelos registradores. Essas etapas efetivamente utilizam o buffer de reordenação e as estações de reserva para implementar a renomeação de registradores. Conceitualmente, você pode pensar em um pipeline escalonado de forma dinâmica como uma análise da estrutura de fluxo de dados de um programa. O processador executa as instruções em alguma ordem que preserva a ordem do fluxo de dados do programa. Esse estilo de execução é chamado de execução fora de ordem, pois as instruções podem ser executadas em uma ordem diferente daquela em que foram apanhadas.
execução fora de ordem Uma situação na execução em pipeline quando uma instrução com execução bloqueada não faz com que as instruções seguintes esperem.
Para fazer com que os programas se comportem como se estivessem executando em um pipeline simples em ordem, a unidade de busca e decodificação de instruções precisa despachar instruções em ordem, o que permite que as dependências sejam acompanhadas, e a unidade de commit precisa escrever resultados nos registradores e na memória na ordem de execução do programa. Esse modo conservador é chamado de commit em ordem. Logo, se houver uma exceção, o computador poderá apontar para a última instrução executada, e os únicos registradores atualizados serão aqueles escritos pelas instruções antes da instrução que causa a exceção. Apesar de o front end (busca e despacho) e o back end (commit) do pipeline executarem em ordem, as unidades funcionais são livres para iniciar a execução sempre que os dados de que precisam estiverem disponíveis. Hoje, todos os pipelines escalonados dinamicamente utilizam o commit em ordem.
commit em ordem Um commit em que os resultados da execução em pipeline são escritos no estado visível ao programador na mesma ordem em que as instruções são buscadas. Em geral, o escalonamento dinâmico é estendido pela inclusão da especulação baseada em hardware, especialmente para resultados de desvios. Prevendo a direção de um desvio, um processador escalonado dinamicamente pode continuar a buscar e executar instruções ao longo do caminho previsto. Como as instruções possuem um commit em ordem, sabemos se o desvio foi previsto corretamente ou não, antes que quaisquer instruções do caminho previsto tenham seus resultados atualizados pelas unidades de commit. Um pipeline especulativo, escalonado dinamicamente, também pode admitir especulação nos endereços de load, permitindo uma reordenação load-store e usando a unidade de commit para evitar a especulação incorreta. Na próxima seção, veremos o uso do escalonamento dinâmico com especulação no projeto do Intel Core i7.
Entendendo o desempenho dos programas Dado que os compiladores também podem escalonar o código em torno das dependências de dados, você poderia perguntar por que um processador superescalar usaria o escalonamento dinâmico. Existem três motivos principais. Primeiro, nem todos os stalls são previsíveis. Em particular, as
falhas de cache (Capítulo 5) na hierarquia de memória causam stalls imprevisíveis. O escalonamento dinâmico permite que o processador oculte alguns desses stalls continuando a executar instruções enquanto esperam que o stall termine.
Segundo, se o processador especula sobre resultados de desvio usando a predição de desvio dinâmica, ele não pode saber a ordem exata das instruções durante a compilação, pois isso depende do comportamento previsto e real dos desvios. A incorporação da especulação dinâmica para explorar mais o paralelismo em nível de instrução (ILP) sem incorporar o escalonamento dinâmico restringiria significativamente os benefícios de tal especulação.
Terceiro, como a latência do pipeline e a largura do despacho mudam de uma implementação para outra, a melhor maneira de compilar uma sequência de código também muda. Por exemplo, a forma de escalonar uma sequência de instruções dependentes é afetada tanto pela largura quanto pela latência do despacho. A estrutura do pipeline afeta o número de vezes que um loop precisa ser desdobrado para evitar stalls e também o processo de renomeação de registradores feito pelo compilador. O escalonamento dinâmico permite que o hardware oculte a maioria desses detalhes. Assim, os usuários e os distribuidores de software não precisam se preocupar em ter várias versões de um programa para diferentes implementações do mesmo conjunto de instruções. De modo semelhante, o código antigo legado receberá grande parte do benefício de uma nova implementação sem a necessidade de recompilação.
Colocando em perspectiva Tanto a técnica de pipelining quanto a execução com despacho múltiplo
aumentam a vazão máxima de instruções e tentam explorar o paralelismo em nível de instrução (ILP). No entanto, as dependências de dados e controle nos programas oferecem um limite superior sobre o desempenho sustentado, pois o processador, às vezes, precisa esperar que uma dependência seja resolvida. As técnicas centradas no software para a exploração do ILP contam com a capacidade do compilador de encontrar e reduzir os efeitos de tais dependências, enquanto as técnicas centradas no hardware contam com extensões para o pipeline e mecanismos de despacho. A especulação, realizada pelo compilador ou pelo hardware, pode aumentar a quantidade de ILP que pode ser explorada por meio da predição, embora se deva ter cuidado, visto que a especulação incorreta provavelmente reduzirá o desempenho.
Interface hardware/software Processadores modernos, de alto desempenho, são capazes de despachar várias instruções por clock; infelizmente, é muito difícil sustentar essa taxa de despacho. Por exemplo, apesar da existência de processadores com despacho de quatro a seis instruções por clock, muito poucas aplicações podem sustentar mais do que duas instruções por clock. Existem dois motivos principais para isso. Primeiro, dentro do pipeline, os principais gargalos no desempenho surgem das dependências que não podem ser aliviadas, reduzindo assim o paralelismo entre as instruções e a velocidade de despacho sustentada. Embora pouca coisa possa ser feita sobre as verdadeiras dependências dos dados, normalmente o compilador ou o hardware não sabe exatamente se uma dependência existe ou não e, por isso, precisa considerar de forma conservadora que a dependência existe. Por exemplo, o código que utiliza ponteiros, principalmente os que criam mais aliasing, levará a dependências em potencial mais implícitas. Ao contrário, a maior regularidade dos acessos a um array normalmente permite que um compilador deduza que não existem dependências. De modo semelhante, os desvios que não podem ser previstos com precisão, seja em tempo de execução ou de compilação, limitarão a capacidade de explorar o ILP. Em geral, o ILP adicional está disponível, mas a capacidade de o compilador ou o hardware encontrar ILP que possa estar bastante separado (às vezes, pela execução de milhares de instruções) é limitada. Em segundo lugar, as perdas na hierarquia da memória (o tópico do Capítulo 5) também limitam a capacidade de manter o pipeline cheio. Alguns stalls do sistema de memória podem ser escondidos, mas quantidades limitadas de ILP também limitam a extensão à qual esses stalls podem ser escondidos.
Eficiência de potência e pipelining avançado A desvantagem do aumento da exploração do paralelismo em nível de instrução por meio do despacho múltiplo dinâmico e especulação é a eficiência de potência. Cada inovação foi capaz de transformar mais transistores em desempenho, mas geralmente eles faziam isso de modo muito ineficaz. Agora que atingimos o muro da potência, estamos vendo projetos com múltiplos processadores por chip em que os processadores não são tão profundamente dispostos em pipeline ou tão agressivamente especulativos quanto seus predecessores. A crença é que, embora os processadores mais simples não sejam tão rápidos quanto seus irmãos sofisticados, eles oferecem melhor desempenho por watt, de modo que podem oferecer mais desempenho por chip quando os projetos são restritos mais por potência do que por número de transistores. A Figura 4.73 mostra o número de estágios de pipeline, largura do despacho, nível de especulação, taxa de clock, núcleos por chip e potência de vários microprocessadores do passado e recentes. Observe a queda nos estágios de pipeline e potência enquanto as empresas passam para projetos multicore.
FIGURA 4.73 Registro dos microprocessadores Intel em termos de complexidade de pipeline, número de cores (núcleos) e potência. Os estágios de pipeline do Pentium 4 não incluem os estágios de commit. Se os incluíssemos, os pipelines do Pentium 4 seriam ainda mais profundos.
Detalhamento Uma unidade de commit controla atualizações no banco de registradores e na memória. Alguns processadores escalonados dinamicamente atualizam o banco de registradores imediatamente durante a execução, usando registradores extras para implementar a função de renomeação e preservar a cópia mais antiga de um registrador até que a instrução atualizando o registrador não seja mais especulativa. Outros processadores mantêm o resultado em buffer, normalmente em uma estrutura chamada buffer de reordenação e a atualização real no banco de registradores ocorre depois, como parte do commit. Stores na memória precisam ser colocados em buffer até o momento do commit, seja em um buffer de store (Capítulo 5) ou no buffer de reordenação. A unidade de commit permite que o store escreva na memória, a partir do buffer, quando ele tiver um endereço com dados válidos e quando o store não for mais dependente de desvios previstos.
Detalhamento Os acessos à memória se beneficiam das caches sem bloqueio, que continuam a atender acessos da cache durante uma falta de cache (Capítulo 5). Os processadores com execução fora de ordem precisam do projeto de cache para permitir que as instruções sejam executadas durante uma falha.
Verifique você mesmo Indique se as técnicas ou componentes a seguir estão associados principalmente a uma técnica baseada em software ou hardware para a exploração do ILP. Em alguns casos, a resposta pode ser “ambos”. 1. Previsão de desvio 2. Despacho múltiplo 3. VLIW 4. Superescalar 5. Escalonamento dinâmico 6. Execução fora de ordem 7. Especulação 8. Buffer de reordenação 9. Renomeação de registradores
4.11. Vida real: pipelines do ARM Cortex-A8 e Intel Core i7 A Figura 4.74 descreve os dois microprocessadores que examinaremos nesta seção, cujos destinos são os dois exemplos típicos da era pós-PC.
FIGURA 4.74 Especificação do ARM Cortex-A8 e do Intel
Core i7 920.
O ARM Cortex-A8 O ARM Cortex-A8 roda a 1 GHz com um pipeline de 14 estágios. Ele usa o despacho múltiplo dinâmico, com duas instruções por ciclo de clock. Ele é um pipeline estático em ordem, no qual as instruções são despachadas, executadas e confirmadas ordenadamente. O pipeline consiste em três seções para busca de instruções, decodificação de instruções e execução. A Figura 4.75 mostra o pipeline geral.
FIGURA 4.75 O pipeline do A8. Os três primeiros estágios leem instruções para um buffer de busca de instrução com 12 entradas. A unidade de geração de endereço (AGU — Address Generation Unit) usa um buffer de destino de desvio (BTB — Branch Target Buffer), buffer de histórico global (GHB — Global History Buffer) e pilha de retorno (RS — Return Stack) para prever desvios a fim de tentar manter cheia a fila de busca. A decodificação de instruções tem cinco estágios e a execução de instruções tem seis estágios.
Os três primeiros estágios buscam duas instruções de uma só vez e tentam manter cheio um buffer de pré-busca com 12 instruções. Ele usa um previsor de desvio de dois níveis usando um buffer de destino de 512 entradas, um buffer de histórico global de 4096 entradas e uma pilha de retorno de 8 entradas. Quando a previsão de desvio é errada, ele esvazia o pipeline, resultando em uma
penalidade na falha de previsão de desvio com 13 ciclos de clock. Os cinco estágios do pipeline de decodificação determinam se existem dependências entre um par de instruções, o que forçaria a execução sequencial, e em qual pipeline dos estágios de execução as instruções são enviadas. Os seis estágios da seção de execução de instrução oferecem um pipeline para instruções load e store e dois pipelines para operações aritméticas, embora somente o primeiro do par possa lidar com multiplicações. Qualquer instrução do par pode ser despachada ao pipeline de load-store. Os estágios de execução possuem bypassing pleno entre os três pipelines. A Figura 4.76 mostra o CPI do A8 usando pequenas versões de programas derivados dos benchmarks SPEC2000. Embora o CPI ideal seja 0,5, o melhor caso é 1,4, o caso mediano é 2,0 e o pior caso é 5,2. Para o caso mediano, 80% dos stalls devem-se aos hazards de pipelining e 20% são stalls devidos à hierarquia de memória. Os stalls do pipeline são causados por falhas de previsão de desvio, hazards estruturais e dependências de dados entre pares de instruções. Devido ao pipeline estático do A8, fica a critério do compilador tentar evitar hazards estruturais e dependências de dados.
FIGURA 4.76 CPI no ARM Cortex A8 para os benchmarks Minnespec, que são pequenas versões dos benchmarks SPEC2000. Estes benchmarks usam as entradas muito menores para reduzir o tempo de execução em várias ordens de grandeza. O tamanho menor subestima significativamente o impacto do CPI da hierarquia de memória (Capítulo 5).
Detalhamento
Detalhamento O Cortex-A8 é um núcleo (core) configurável que tem suporte para a arquitetura do conjunto de instrução ARMv7. Ele é entregue como um núcleo de propriedade intelectual (IP — Intellectual Property). Núcleos IP são a forma dominante de entrega de tecnologia nos mercados de dispositivos móveis embutidos, pessoais e relacionados; bilhões de processadores ARM e MIPS foram criados a partir desses núcleos IP. Observe que os núcleos IP são diferentes dos núcleos nos computadores Intel i7 multicore. Um núcleo IP (que pode, por si só, ser um multicore) é projetado para ser incorporado com outra lógica (daí ele ser um “núcleo” de um chip), incluindo processadores específicos da aplicação (como um codificador ou decodificador para vídeo), interfaces de E/S e interfaces de memória, e depois fabricado para produzir um processador otimizado para uma aplicação em particular. Embora o núcleo do processador seja quase idêntico, os chips resultantes possuem muitas diferenças. Um parâmetro é o tamanho da cache L2, que pode variar por um fator de oito.
O Intel Core i7 920 Os microprocessadores x86 empregam técnicas sofisticadas de pipelining, usando o despacho múltiplo dinâmico e o escoamento de pipeline dinâmico com execução fora de ordem e especulação para o seu pipeline de 14 estágios. Porém, esses processadores ainda enfrentam o desafio de implementar o complexo conjunto de instruções do x86, descrito no Capítulo 2. O processador Intel busca instruções x86 e as traduz em instruções internas tipo MIPS, que a Intel chama de micro-operações. As micro-operações são então executadas por um pipeline sofisticado, especulativo e dinamicamente escalonado, capaz de sustentar a taxa de execução de até seis micro-operações por ciclo de clock. Esta seção é focada nesse pipeline de micro-operações. Quando consideramos o projeto de processadores sofisticados, escalonados dinamicamente, o projeto das unidades funcionais, da cache e banco de registradores, do despacho de instruções e do controle geral do pipeline tornamse algo combinado, dificultando a separação entre o caminho de dados e o pipeline. Por causa disso, muitos engenheiros e pesquisadores têm adotado o termo microarquitetura para se referirem à arquitetura interna detalhada de um processador.
microarquitetura A organização do processador, incluindo as principais unidades funcionais, sua interconexão e controle. O Intel Core i7 utiliza um esquema para resolver as antidependências e a especulação incorreta, que usa um buffer de reordenação junto com a renomeação de registradores. A renomeação de registradores renomeia explicitamente os registradores arquitetônicos em um processador (16 no caso da versão de 64 bits da arquitetura x86) para um conjunto maior de registradores físicos. O Core i7 utiliza a renomeação de registradores para remover as antidependências. A renomeação de registradores exige que o processador mantenha um mapa entre os registradores arquitetônicos e os registradores físicos, indicando qual registrador físico é a cópia mais atual de um registrador arquitetônico. Registrando as renomeações que ocorreram, a técnica oferece outra forma de recuperação no caso de especulação incorreta: basta desfazer os mapeamentos que ocorreram desde a primeira instrução especulada incorretamente. Isso fará com que o estado do processador retorne à última instrução executada corretamente, mantendo o mapeamento correto entre os registradores arquitetônicos e físicos.
registradores arquitetônicos O conjunto de instruções dos registradores visíveis de um processador; por exemplo, no MIPS, existem 32 registradores de inteiros e 32 de ponto flutuante. A Figura 4.77 mostra a organização geral e o pipeline do Core i7. A seguir estão as oito etapas pelas quais uma instrução x86 passa para a sua execução. 1. Busca de instrução — O processador utiliza um buffer de destino de desvio multinível para obter um equilíbrio entre velocidade e exatidão na previsão. Há também uma pilha de endereços de retorno para agilizar o retorno de função. Falhas de previsão causam uma penalidade de aproximadamente 15 ciclos. Usando o endereço previsto, a unidade de busca de instruções lê 16 bytes da cache de instruções.
FIGURA 4.77 O pipeline do Core i7 com componentes de memória. A profundidade total do pipeline é de 14 estágios, com as falhas de previsão de desvio custando 17 ciclos de clock. Esse projeto pode manter em buffer 48 loads e 32 stores. As seis unidades independentes podem iniciar a execução de uma operação RISC pronta a cada ciclo de clock.
2. Os 16 bytes são colocados no buffer de instrução pré-decodificação — O estágio de pré-decodificação transforma os 16 bytes em instruções x86 individuais. Essa pré-decodificação é não trivial, pois o comprimento de uma instrução x86 pode ser de 1 a 15 bytes, e o pré-decodificador precisa percorrer uma série de bytes, antes de descobrir o comprimento da
instrução. As instruções x86 individuais são colocadas na filha de instruções com 18 entradas. 3. Decodificação de micro-operação — As instruções x86 individuais são traduzidas em micro-operações (micro-ops). Três dos decodificadores tratam das instruções x86 que se traduzem diretamente em uma micro-op. Para instruções x86 que possuem semântica mais complexa, existe um mecanismo de microcódigo usado para produzir a sequência de micro-op; ele pode produzir até quatro micro-ops a cada ciclo e continua até que a sequência de micro-ops necessária tenha sido gerada. As micro-ops são posicionadas de acordo com a ordem das instruções x86 no buffer de micro-ops com 28 entradas. 4. O buffer de micro-op realiza a detecção de stream do loop — Se houver uma pequena sequência de instruções (menos de 28 instruções ou 256 bytes de extensão) que compreende um loop, o detector de stream do loop encontrará o loop e despachará diretamente as micro-ops a partir do buffer, eliminando a necessidade de ativação dos estágios de busca e decodificação de instruções. 5. Realizar o despacho básico da instrução — Pesquisar o local do registrador nas tabelas de registradores, renomear os registradores, alocar uma entrada no buffer de reordenação e buscar quaisquer resultados dos registradores ou buffer de reordenação antes de enviar as micro-ops para as estações de reserva. 6. O i7 usa uma estação de reserva centralizada com 36 entradas, compartilhada por seis unidades funcionais. Até seis micro-ops podem ser despachadas para as unidades funcionais a cada ciclo de clock. 7. As unidades funcionais individuais executam micro-ops e depois os resultados são enviados de volta a qualquer estação de reserva aguardando, bem como para a unidade de afastamento de registrador, onde atualizarão o estado do registrador, quando se souber que a instrução não é mais especulativa. A entrada correspondente à instrução no buffer de reordenação é marcada como completa. 8. Quando uma ou mais instruções no início do buffer de reordenação forem marcadas como completas, as escritas pendentes na unidade de afastamento de registrador são executadas, e as instruções são removidas do buffer de reordenação.
Detalhamento
O hardware na segunda e quarta etapas pode combinar ou fundir operações para reduzir o número de operações que precisam ser realizadas. A fusão de macro-op na segunda etapa exige que se combine instruções x86, como uma comparação seguida de um desvio, e que se junte em uma única operação. A microfusão na quarta etapa combina pares de micro-operações, como load/operação ALU e operação ALU/store, e os despacha para uma única estação de reserva (onde ainda poderão ser despachados independentemente), aumentando assim a utilização do buffer. Em um estudo da arquitetura Intel Core, que também incorporou a microfusão e a macrofusão, Bird et al. (2007) descobriram que a microfusão tinha pouco impacto sobre o desempenho, enquanto a macrofusão parece ter um impacto positivo moderado sobre o desempenho de inteiros e pouco impacto sobre o desempenho de ponto flutuante.
Desempenho do Intel Core i7 920 A Figura 4.78 mostra o CPI do Intel Core i7 para cada um dos benchmarks SPEC2006. Embora o CPI ideal seja 0,25, o melhor caso é 0,44, o caso mediano é 0,79 e o pior caso é 2,67.
FIGURA 4.78 CPI do Intel Core i7 920 rodando benchmarks de inteiros do SPEC2006.
Embora seja difícil diferenciar entre stalls do pipeline e stalls da memória em um pipeline de execução dinâmica fora de ordem, podemos mostrar a eficácia da previsão de desvio e da especulação. A Figura 4.79 mostra a porcentagem dos desvios mal previstos e a porcentagem do trabalho (medida pelo número de micro-ops despachadas para o pipeline) que não se ausenta (ou seja, seus resultados são anulados) em relação a todos os despachos de micro-op. O mínimo, mediano e máximo das falhas de previsão de desvio são 0%, 2% e 10%. Para o trabalho desperdiçado, eles são 1%, 18% e 39%.
FIGURA 4.79 Porcentagem de erros de previsão de desvio e trabalho desperdiçado devido à especulação improdutiva do Intel Core i7 920 rodando benchmarks de inteiros do SPEC2006.
O trabalho desperdiçado, em alguns casos, corresponde de perto às taxas de falha na previsão de desvio, como para os benchmarks gobmk e astar. Em vários casos, como em mcf, o trabalho desperdiçado parece ser relativamente maior do que a taxa de falha de previsão. Essa divergência provavelmente se deve ao comportamento da memória. Com taxas de falta de cache de dados muito altas, mcf despachará muitas instruções durante uma especulação incorreta, desde que haja estações de reserva suficientes à disposição para as referências de memória adiadas. Quando um desvio entre as muitas instruções especuladas for finalmente mal previsto, as micro-ops correspondentes a todas essas instruções sofrerão flush.
Entendendo o desempenho dos programas O Intel Core i7 combina um pipeline de 14 estágios e despacho múltiplo agressivo para conseguir alto desempenho. Mantendo baixas as latências para operações back to back, o impacto das dependências de dados é reduzido. Quais são os gargalos de desempenho em potencial mais sérios para os
programas executados nesse processador? A lista a seguir inclui alguns problemas de desempenho em potencial, com os três últimos podendo se aplicar, de alguma forma, a qualquer processador com pipeline de alto desempenho. ▪ O uso de instruções x86 que não são mapeadas para algumas microoperações simples. ▪ Desvios que são difíceis de se prever, causando stalls e reinícios mal previstos quando a especulação falha. ▪ Dependências longas — normalmente causadas por instruções duradouras ou pela hierarquia de memória — que causam stalls. ▪ Atrasos de desempenho que surgem no acesso à memória (Capítulo 5), fazendo com que o processador sofra stall.
4.12. Mais rápido: Paralelismo em nível de instrução e multiplicação matricial Retornando ao exemplo do DGEMM do Capítulo 3, podemos ver o impacto do paralelismo em nível de instrução desdobrando o loop de modo que o processador com execução de despacho múltiplo, fora de ordem, tenha mais
instruções com que trabalhar. A Figura 4.80 mostra a versão desdobrada da Figura 3.23, que contém os intrínsecos da linguagem C para produzir as instruções AVX.
FIGURA 4.80 Versão C otimizada do DGEMM usando intrínsecos da linguagem C para gerar as instruções paralelas de subword AVX para o x86 (Figura 3.23) e desdobramento de loop para criar mais oportunidades de paralelismo em nível de instrução. A Figura 4.81 mostra o código em linguagem assembly produzido pelo compilador para o loop mais interno, que desdobra os três corpos de loop for a fim de expor o paralelismo em nível de instrução.
Assim como o exemplo de desdobramento na Figura 4.71, vamos desdobrar o loop 4 vezes. (Usamos a constante UNROLL no código C para controlar a quantidade de desdobramento caso queiramos experimentar outros valores.) Em vez de desdobrar manualmente o loop em C fazendo 4 cópias de cada um dos intrínsecos da Figura 3.23, podemos contar com o compilador gcc para fazer o desdobramento na otimização –O3. Delimitamos cada intrínseco com um loop
for simples com 4 iterações (linhas 9, 14 e 20) e substituímos o escalar C0 da Figura 3.23 por um array de 4 elementos c[] (linhas 8, 10, 16 e 21). A Figura 4.81 mostra a saída em linguagem assembly do código desdobrado. Conforme esperado, na Figura 4.81, existem 4 versões de cada uma das instruções AVX da Figura 3.24, com uma exceção. Só precisamos de uma cópia da instrução vbroadcastsd, pois podemos usar as quatro cópias do elemento B no registrador %ymm0 repetidamente por todo o loop. Assim, as 5 instruções AVX da Figura 3.24 tornam-se 17 na Figura 4.81, e as 7 instruções de inteiros aparecem em ambas, embora as constante e o endereçamento mudem para levar em conta o desdobramento. Portanto, apesar de desdobrar 4 vezes, o número de instruções no corpo do loop só dobra: de 12 para 24.
FIGURA 4.81 A linguagem assembly x86 para o corpo dos
loops aninhados gerados pela compilação do código C desdobrado na Figura 4.80.
A Figura 4.82 mostra o aumento de desempenho do DGEMM para matrizes 32 × 32 passando de não otimizado para AVX e depois para AVX com desdobramento. O desdobramento mais do que duplica o desempenho, passando de 6,4 GFLOPS para 14,6 GFLOPS. As otimizações para paralelismo de subword e paralelismo em nível de instrução resultam em um ganho de velocidade geral de 8,8 versus o DGEMM não otimizado da Figura 3.21.
FIGURA 4.82 Desempenho de três versões do DGEMM para matrizes 32 × 32. O paralelismo de subword e o paralelismo em nível de instrução levaram a um ganho de velocidade de quase 9 em relação ao código não otimizado da Figura 3.21.
Detalhamento Como dissemos no Detalhamento da Seção 3.8, esses resultados são com o modo Turbo desativado. Se o ativarmos, como no Capítulo 3, melhoramos
todos os resultados pelo aumento temporário na taxa de clock de 3,3/2,6 = 1,27, passando a 2,1 GFLOPS para o DGEMM não otimizado, 8,1 GFLOPS com AVX e 18,6 GFLOPS com desdobramento e AVX. Como dissemos na Seção 3.8, o modo Turbo funciona particularmente bem nesse caso, porque está usando apenas um único núcleo de um chip de oito núcleos.
Detalhamento Não existem stalls de pipeline apesar da reutilização do registrador %ymm5 nas linhas de 9 a 17 da Figura 4.81, pois o pipeline do Intel Core i7 renomeia os registradores.
Verifique você mesmo Indique se cada uma das afirmações a seguir é verdadeira ou falsa. 1. O Intel Core i7 utiliza um pipeline com despacho múltiplo para executar as instruções X86 diretamente. 2. Tanto o A8 quanto o Core i7 utilizam o despacho múltiplo dinâmico. 3. A microarquitetura do Core i7 possui muito mais registradores do que o x86 exige. 4. O Intel Core i7 usa menos de metade dos estágios do pipeline do Intel Pentium 4 Prescott mais antigo (Figura 4.73).
4.13. Falácias e armadilhas Falácia: Pipelining é fácil. Nossos livros comprovam a sutileza da execução correta de um pipeline. Nosso livro avançado tinha um bug no pipeline em sua primeira edição, apesar de ter sido revisado por mais de 100 pessoas e testado nas salas de aula de 18 universidades. O bug só foi descoberto quando alguém tentou montar um computador com aquele livro. O fato de que o Verilog para descrever um pipeline como esse do Intel Core i7 terá milhares de linhas é uma indicação da complexidade. Esteja atento! Falácia: As ideias de pipelining podem ser implementadas independentes da
tecnologia. Quando o número de transistores no chip e a velocidade dos transistores tornaram um pipeline de cinco estágios a melhor solução, então o delayed branch (veja o primeiro “Detalhamento” da Seção “Previsão dinâmica de desvios”) foi uma solução simples para controlar os hazards. Com pipelines maiores, a execução superescalar e a previsão dinâmica de desvios, isso agora é redundante. No início da década de 1990, o escalonamento dinâmico em pipeline exigia muitos recursos e não era necessário para o alto desempenho, mas, à medida que a quantidade de transistores continuava a dobrar, devido à Lei de Moore, a lógica se tornava muito mais rápida do que a memória, então as múltiplas unidades funcionais e os pipelines dinâmicos fizeram mais sentido. Hoje, a preocupação com a potência está levando a projetos menos agressivos.
Armadilha: A falha em considerar o projeto do conjunto de instruções pode afetar o pipeline de forma adversa. Muitas das dificuldades em pipelining surgem por causa das complicações do
conjunto de instruções. Aqui estão alguns exemplos: ▪ Tamanhos de instrução e tempos de execução muito variáveis podem causar desequilíbrio entre estágios do pipeline e complicar bastante a detecção de hazards em um projeto com pipeline, no nível do conjunto de instruções. Esse problema foi contornado, inicialmente no DEC VAX 8500, no final da década de 1980, usando o esquema de micro-operações e micropipeline que o Intel Core i7 emprega hoje. Naturalmente, o overhead da tradução e a manutenção da correspondência entre as micro-operações e as instruções permanecem. ▪ Modos de endereçamento sofisticados podem levar a diferentes tipos de problemas. Os modos de endereçamento que atualizam registradores complicam a detecção de hazards. Outros modos de endereçamento que exigem múltiplos acessos à memória complicam bastante o controle do pipeline e tornam difícil manter o pipeline fluindo tranquilamente. ▪ Talvez o melhor exemplo seja o DEC Alpha e o DEC NVAX. Em uma tecnologia comparável, o conjunto de instruções mais recente do Alpha permitiu uma implementação cujo desempenho tem mais do que o dobro da velocidade do NVAX. Em outro exemplo, Bhandarkar e Clark [1991] compararam o MIPS M/2000 e o DEC VAX 8700 contando os ciclos de clock dos benchmarks SPEC; eles concluíram que, embora o MIPS M/2000 execute mais instruções, o VAX na média executa 2,7 vezes mais ciclos de clock, de modo que o MIPS é mais rápido.
4.14. Comentários finais Noventa por cento da sabedoria consiste em ser sensato no tempo. Provérbio americano
Como vimos neste capítulo, tanto o caminho de dados quanto o controle para um processador podem ser projetados começando com a arquitetura do conjunto de instruções e o conhecimento das características básicas da tecnologia. Na Seção 4.3, vimos como o caminho de dados para um processador MIPS poderia ser construído com base na arquitetura e na decisão de criar uma implementação de ciclo único. Naturalmente, a tecnologia básica também afeta muitas decisões de projeto, ditando quais componentes podem ser usados no caminho de dados e também se uma implementação de ciclo único sequer faz sentido.
A técnica de pipelining melhora a vazão, mas não o tempo de execução inerente (ou latência de instrução) das instruções; para algumas instruções, a latência é semelhante, em duração, à técnica de ciclo único. O despacho de instrução múltiplo acrescenta um hardware adicional ao caminho de dados para permitir que várias instruções sejam iniciadas a cada ciclo de clock, mas com um aumento na latência efetiva. O pipelining foi apresentado como capaz de reduzir o tempo de ciclo de clock do caminho de dados do ciclo único simples. O despacho múltiplo de instruções, em comparação, focaliza claramente na redução dos ciclos de clock por instrução (CPI).
latência de instrução O tempo de execução inerente para uma instrução. A técnica de pipelining e o despacho múltiplo tentam explorar o paralelismo em nível de instrução. A presença de dependências de dados e de controle, que podem se tornar hazards, são as principais limitações para a exploração do paralelismo. Escalonamento e especulação por predição, tanto no hardware
quanto no software, são as principais técnicas utilizadas para reduzir o impacto das dependências sobre o desempenho.
Mostramos que o desdobramento do loop DGEMM expõe quatro vezese mais instruções que poderiam tirar proveito do mecanismo de execução fora de ordem do Core i7 para mais do que dobrar o desempenho. A passagem para pipelines maiores, despacho de instruções múltiplas e escalonamento dinâmico em meados da década de 1990 ajudou a sustentar os 60% de aumento anual de desempenho dos processadores que começou no início da década de 1980. Como dissemos no Capítulo 1, esses microprocessadores preservaram o modelo de programação sequencial, mas por fim se chocaram com o muro da potência. Assim, a indústria foi forçada a testar multiprocessadores, que exploram o paralelismo em níveis menos minuciosos (o assunto do Capítulo 6). Essa tendência também fez com que os projetistas reavaliassem as implicações de desempenho da potência de algumas invenções desde meados da década de 1990, resultando em uma simplificação dos pipelines
em versões mais recentes das microarquiteturas. Para sustentar os avanços no desempenho de processamento por meio de processadores paralelos, a lei de Amdahl sugere que outra parte do sistema se torne o gargalo. Esse gargalo é o assunto do próximo capítulo: a hierarquia da memória.
4.15. Exercícios 4.1 Considere a seguinte instrução: Instrução: AND Rd,Rs,Rt Interpretação: Reg[Rd] = Reg[Rs] AND Reg[Rt] 4.1.1 [5] Quais são os valores dos sinais de controle gerados pelo controle na Figura 4.2 para esta instrução? 4.1.2 [5] Quais recursos (blocos) realizam uma função útil para essa instrução? 4.1.3 [10] Quais recursos (blocos) produzem saídas, mas suas saídas não são usadas para essa instrução? Quais recursos não produzem saídas para ela? 4.2 A implementação básica de ciclo único do MIPS na Figura 4.2 só pode implementar algumas instruções. Novas instruções podem ser acrescentadas
a uma ISA (Instruction Set Architecture) existente, mas a decisão de fazer isso ou não depende, entre outras coisas, do custo e da complexidade que tal acréscimo introduz no caminho de dados e controle do processador. Os três primeiros problemas neste exercício referem-se a esta nova instrução: Instrução: LWI Rt,Rd(Rs) Interpretação: Reg[Rt] = Mem[Reg[Rd] + Reg[Rs]] 4.2.1 [10] Quais blocos existentes (se houver) podem ser usados para essa instrução? 4.2.2 [10] De quais novos blocos funcionais (se houver) precisamos para essa instrução? 4.2.3 [10] De quais novos sinais da unidade de controle (se houver) precisamos para dar suporte a essa instrução? 4.3 Quando os projetistas de processador consideram uma melhoria possível no caminho de dados do processador, a decisão normalmente depende da escolha de custo/desempenho. Nos três problemas a seguir, considere que estamos começando com um caminho de dados da Figura 4.2, em que os blocos I-Mem, Add, Mux, ALU, Regs, D-Mem e de Controle têm latências de 400 ps, 100 ps, 30 ps, 120 ps, 200 ps, 350 ps e 100 ps, respectivamente, e custos de 1000, 30, 10, 100, 200, 2000 e 500, respectivamente. Considere o acréscimo de um multiplicador à ALU. Esse acréscimo somará 300 ps à latência da ALU e acrescentará um custo de 600 à ALU. O resultado será 5% menos instruções executadas, pois não precisaremos mais emular a instrução MUL. 4.3.1 [10] Qual é o tempo de ciclo de clock com e sem essa melhoria? 4.3.2 [10] Qual é o ganho de velocidade obtido acrescentando essa melhoria? 4.3.3 [10] Compare a razão custo/desempenho com e sem essa melhoria. 4.4 Os problemas neste exercício consideram que os blocos lógicos necessários para implementar o caminho de dados de um processador têm as seguintes latências: I-Mem
Add
Mux
ALU
Regs
200 ps
70 ps
20 ps
90 ps
90 ps
D-Mem Extensão de sinal Shift-esq-2 250 ps
15 ps
10 ps
4.4.1 [10] Se a única coisa que precisássemos fazer em um processador fosse buscar instruções consecutivas (Figura 4.6), qual seria o tempo do ciclo?
4.4.2 [10] Considere um caminho de dados semelhante ao da Figura 4.11, mas para um processador que só tem um tipo de instrução: desvio incondicional relativo ao PC. Qual seria o tempo de ciclo para esse caminho de dados? 4.4.3 [10] Repita o Exercício 4.4.2, mas desta vez precisamos dar suporte apenas a desvios condicionais relativos ao PC. Os três problemas restantes neste exercício referem-se ao elemento Shiftesq-2 no caminho de dados: 4.4.4 [10] Quais tipos de instruções exigem esse recurso? 4.4.5 [20] Para que tipos de instruções (se houver) esse recurso está no caminho crítico? 4.4.6 [10] Supondo que só temos suporte para instruções beq e add, discuta como as mudanças na latência indicada desse recurso afetam o tempo de ciclo do processador. Suponha que as latências de outros recursos não mudem. 4.5 Para os problemas neste exercício, considere que não existem stalls de pipeline e que o desmembramento das instruções executadas seja o seguinte: add addi not
beq
lw
sw
20% 20% 0% 25% 25% 10%
4.5.1 [10] Em que fração de todos os ciclos a memória de dados é utilizada? 4.5.2 [10] Em que fração de todos os ciclos a entrada do circuito por extensão de sinal é necessária? O que esse circuito está fazendo nos ciclos em que sua entrada não é necessária? 4.6 Quando os chips de silício são fabricados, os defeitos nos materiais (por exemplo, o silício) e os erros de manufatura podem resultar em circuitos defeituosos. Um defeito muito comum é quando um fio afeta o sinal em outro. Isso é chamado de falha cross-talk. Uma classe especial de falhas cross-talk é quando um sinal está conectado a um fio que tem um valor lógico constante (por exemplo, um fio da fonte de alimentação). Nesse caso, temos uma falha stuck-at-0 ou stuck-at-1, e o sinal afetado sempre tem um valor lógico 0 ou 1, respectivamente. Os problemas a seguir referem-se ao bit 0 do Registrador Escrita no banco de registradores da Figura 4.24. 4.6.1 [10] Vamos supor que o teste do processador seja feito preenchendo o PC, registradores e memórias de dados e instruções com alguns valores (você pode escolher quais valores), permitindo que uma única
instrução seja executada e depois lendo o PC, memórias e registradores. Esses valores são então examinados para determinar se uma falha em particular está presente. Você conseguiria criar um teste (valores para PC, memórias e registradores) que determinaria se existe uma falha stuck-at-0 nesse sinal? 4.6.2 [10] Repita o Exercício 4.6.1 para uma falha stuck-at-1. Você conseguiria usar um único teste para stuck-at-0 e stuck-at-1? Caso afirmativo, explique como; se não, explique por que não. 4.6.3 [60] Se soubermos que o processador tem uma falha stuckat-1 nesse sinal, o processador ainda é utilizável? Para isso, temos de converter qualquer programa que execute em um processador MIPS normal em um programa que funcione nesse processador. Você pode considerar que existe memória de instrução e memória de dados livre suficiente para tornar o programa maior e armazenar dados adicionais. Dica: o processador é utilizável se cada instrução “rompida” por essa falha puder ser substituída por uma sequência de instruções “funcionais” que conseguem o mesmo efeito. 4.6.4 [10] Repita o Exercício 4.6.1, mas agora o teste é se o sinal de controle “MemRead” torna-se 0 se o sinal de controle RegDst for 0; caso contrário, nenhuma falha. 4.6.5 [10] Repita o Exercício 4.6.4, mas agora o teste é se o sinal de controle “Jump” torna-se 0 se o sinal de cont RegDst for 0; caso contrário, nenhuma falha. 4.7 Neste exercício, examinamos detalhadamente como uma instrução é executada em um caminho de dados de ciclo único. Os problemas neste exercício referem-se a um ciclo de clock em que o processador busca a seguinte word de instrução: 10101100011000100000000000010100
Considere que a memória de dados contém apenas zeros e que os registradores do processador possuem os seguintes valores no início do ciclo em que a word de instrução anterior é apanhada: r0
r1
r2
r3
r4
r5
r6
r8
r12
r31
0
–1
2
–3
–4
10
6
8
2
–16
4.7.1 [5] Quais são as saídas da unidade de extensão de sinal e salto “Shift left 2” (topo da Figura 4.24) para essa palavra de instrução? 4.7.2 [10] Quais são os valores das entradas da unidade de controle da
ALU para essa instrução? 4.7.3 [10] Qual é o novo endereço do PC após a execução dessa instrução? Destaque o caminho através do qual esse valor é determinado. 4.7.4 [10] Para cada Mux, mostre os valores de sua saída de dados durante a execução dessa instrução e esses valores de registrador. 4.7.5 [10] Para a ALU e as duas unidades de soma, quais são seus valores de entrada de dados? 4.7.6 [10] Quais são os valores de todas as entradas para a unidade de “Registradores”? 4.8 Neste exercício, examinamos como o pipelining afeta o tempo do ciclo de clock do processador. Os problemas neste exercício consideram que os estágios individuais do caminho de dados têm as seguintes latências: IF
ID
EX
MEM
WB
250 ps
350 ps
150 ps
300 ps
200 ps
Além disso, considere que as instruções executadas pelo processador são desmembradas da seguinte forma: alu
beq
lw
sw
45% 20% 20% 15%
4.8.1 [5] Qual é o tempo do ciclo de clock em um processador com e sem pipeline? 4.8.2 [10] Qual é a latência total de uma instrução LW em um processador com e sem pipeline? 4.8.3 [10] Se pudessemos dividir um estágio do caminho de dados com pipeline em dois novos estágios, cada um com metade da latência do estágio original, que estágio dividiriamos e qual é o novo tempo do ciclo de clock do processador? 4.8.4 [10] Supondo que não haja stalls ou hazards, qual é a utilização da memória de dados? 4.8.5 [10] Supondo que não haja stalls ou hazards, qual é a utilização da porta de escrita de registrador da unidade “Registradores”? 4.8.6 [30] Em vez de uma organização de ciclo único, podemos usar uma organização multiciclos, em que cada instrução ocupa múltiplos ciclos, mas uma instrução termina antes que outra seja apanhada. Nessa organização, uma instrução só percorre os estágios que ela realmente precisa
(por exemplo, ST só ocupa quatro ciclos, pois não precisa do estágio WB). Compare os tempos do ciclo de clock e os tempos de execução com a organização em ciclo único, multiciclos e em pipeline. 4.9 Neste exercício, examinamos como as dependências de dados afetam a execução no pipeline básico de cinco estágios descrito na Seção 4.5. Os problemas neste exercício referem-se a esta sequência de instruções:
Além disso, considere os seguintes tempos do ciclo de clock para cada uma das opções relacionadas ao forwarding: Sem forwarding Com forwarding completo Apenas com forwarding ALU-ALU 250 ps
300 ps
290 ps
4.9.1 [10] Indique as dependências e seu tipo. 4.9.2 [10] Suponha que não haja forwarding nesse processador em pipeline. Indique hazards e acrescente instruções nop para eliminá-los. 4.9.3 [10] Suponha que haja forwarding completo. Indique os hazards e acrescente instruções nop para eliminá-los. 4.9.4 [10] Qual é o tempo de execução total dessa sequência de instruções sem forwarding e com forwarding completo? Qual é o ganho de velocidade obtido, acrescentando-se forwarding completo a um pipeline que não tinha forwarding? 4.9.5 [10] Acrescente instruções nop a esse código para eliminar hazards se houver apenas forwarding ALU-ALU (nenhum forwarding do estágio MEM para EX). 4.9.6 [10] Qual é o tempo de execução total dessa sequência de instruções apenas com forwarding ALU-ALU? Qual é o ganho de velocidade em relação a um pipeline sem forwarding? 4.10 Neste exercício, examinamos como os hazards de recursos, os hazards de
controle e o projeto da ISA podem afetar a execução em pipeline. Os problemas neste exercício referem-se ao seguinte fragmento de código MIPS:
Considere que os estágios de pipeline individuais possuem as seguintes latências: IF
ID
EX
MEM
WB
200 ps
120 ps
150 ps
190 ps
100 ps
4.10.1 [10] Para este problema, suponha que todos os desvios sejam perfeitamente previstos (isso elimina todos os hazards de controle) e que nenhum slot de delay seja utilizado. Se tivermos apenas uma memória (para instruções e dados), haverá um hazard estrutural toda vez que precisarmos apanhar uma instrução no mesmo ciclo em que outra instrução acessa dados. Para garantir o processo do forwarding, esse hazard sempre precisa ser resolvido em favor da instrução que acessa dados. Qual é o tempo de execução total dessa sequência de instruções no pipeline de cinco estágios que tem apenas uma memória? Vimos que os hazards de dados podem ser eliminados acrescentando nops ao código. Você conseguiria fazer o mesmo com esse hazard estrutural? Por quê? 4.10.2 [20] Para este problema, suponha que todos os desvios sejam perfeitamente previstos (isso elimina todos os hazards de controle) e que nenhum slot de delay seja utilizado. Se mudarmos as instruções load/store para usar um registrador (sem um offset) como endereço, essas instruções não precisam mais usar a ALU. Como resultado, os estágios MEM e EX podem ser sobrepostos e o pipeline tem apenas quatro estágios. Mude esse código para acomodar essa ISA alterada. Supondo que essa mudança não
afete o tempo do ciclo de clock, que ganho de velocidade é obtido nessa sequência de instruções? 4.10.3 [10] Considerando stall-on-branch e nenhum slot de delay, que ganho de velocidade é obtido nesse código se os resultados do desvio forem determinados no estágio ID, em relação à execução em que os resultados do desvio são determinados no estágio EX? 4.10.4 [10] Dadas essas latências de estágio de pipeline, repita o cálculo de ganho de velocidade de 4.10.2, mas leve em conta a (possível) mudança no tempo do ciclo de clock. Quando EX e MEM são feitos em um único estágio, a maior parte do trabalho pode ser feita em paralelo. Como resultado, o estágio EX/MEM resultante tem uma latência que é a maior das duas originais, mais 20 ps necessários para o trabalho que poderia ser feito em paralelo. 4.10.5 [10] Dadas essas latências de estágio em pipeline, repita o cálculo de ganho de velocidade de 4.10.3, mas leve em conta a (possível) mudança no tempo do ciclo de clock. Suponha que a latência do estágio ID aumente em 50% e a latência do estágio EX diminua em 10 ps quando a resolução do resultado do desvio é passada de EX para ID. 4.10.6 [10] Considerando stall-on-branch e nenhum slot de delay, qual é o novo tempo do ciclo de clock e tempo de execução dessa sequência de instruções se o cálculo de endereço de beq for passado para o estágio MEM? Qual é o ganho de velocidade decorrente dessa mudança? Suponha que a latência do estágio EX seja reduzida em 20 ps e a latência do estágio MEM fique inalterada quando a resolução do resultado do desvio for passada de EX para MEM. 4.11 Considere o loop a seguir.
Considere que a previsão de desvio perfeita é utilizada (sem stalls devido aos hazards de controle), que não existem slots de delay e que o pipeline possui suporte para forwarding completo. Considere também que muitas iterações desse loop são executadas antes que o loop termine. 4.11.1 [10] Mostre um diagrama de execução de pipeline para a terceira iteração desse loop, do ciclo em que apanhamos a primeira instrução dessa iteração até (mas não incluindo) o ciclo em que apanhamos a primeira instrução da iteração seguinte. Mostre todas as instruções que estão no pipeline durante esses ciclos (não apenas aquelas da terceira iteração). 4.11.2 [10] Com que frequência (como uma porcentagem de todos os ciclos) temos um ciclo em que todos os cinco estágios do pipeline estão realizando trabalho útil? 4.12 Este exercício tem por finalidade ajudá-lo a entender as escolhas entre custo/complexidade/desempenho do forwarding em um processador com pipeline. Os problemas neste exercício referem-se aos caminhos de dados em pipeline da Figura 4.45. Esses problemas consideram que, de todas as instruções executadas em um processador, a fração dessas instruções a seguir tem um tipo particular de dependência de dados RAW. O tipo de dependência de dados RAW é identificado pelo estágio que produz o resultado (EX ou MEM) e a instrução que consome o resultado (1ª instrução que segue aquela que produz o resultado, 2ª instrução que a segue ou ambas). Consideramos que a escrita do registrador é feita na primeira metade do ciclo de clock e que as leituras do registrador são feitas na segunda metade do
ciclo, de modo que dependências “EX para 3ª” e “MEM para 3ª” não são contadas, pois não podem resultar em hazards de dados. Além disso, considere que o CPI do processador é 1 se não houver hazards de dados. EX para 1ª somente
MEM para 1ª somente
EX para 2ª somente
MEM para 2ª somente
EX para 1ª e MEM para 2ª
Outras dependências RAW
5%
20%
5%
10%
10%
10%
Considere as seguintes latências para estágios individuais do pipeline. No estágio EX, as latências são dadas separadamente para um processador sem forwarding e um processador com diferentes tipos de forwarding. IF
ID
EX (sem FW)
EX (FW completo)
EX (FW apenas de EX/MEM)
EX (FW apenas de MEM/WB)
MEM
WB
150 ps
100 ps
120 ps
150 ps
140 ps
130 ps
120 ps
100 ps
4.12.1 [10] Se não usarmos forwarding, em que fração dos ciclos estamos realizando stall devido aos hazards de dados? 4.12.2 [5] Se usarmos o forwarding completo (encaminhar todos os resultados que podem ser encaminhados), em que fração dos ciclos estamos realizando stall devido aos hazards de dados? 4.12.3 [10] Vamos supor que não tenhamos recursos para ter Muxes de três entradas que são necessários para o forwarding completo. Temos de decidir se é melhor encaminhar apenas do registrador de pipeline EX/MEM (forwarding do próximo ciclo) ou apenas do registrador de pipeline MEM/WB (forwarding de dois ciclos). Qual das duas opções resulta em menos ciclos de stall de dados? 4.12.4 [10] Para as possibilidades de hazard e latências de estágio de pipeline indicadas, qual é o ganho de velocidade obtido acrescentando-se forwarding completo a um pipeline que não tinha forwarding? 4.12.5 [10] Qual seria o ganho de velocidade adicional (relativo a um processador com forwarding) se acrescentássemos o forwarding de retorno no tempo que elimina todos os hazards de dados? Suponha que o circuito de retorno no tempo ainda a ser inventado acrescente 100 ps à latência do estágio EX de forwarding completo. 4.12.6 [20] Repita o Exercício 4.12.3, mas desta vez determine quais das duas opções resulta em menor tempo por instrução. 4.13 Este exercício tem por finalidade ajudá-lo a entender o relacionamento
entre forwarding, detecção de hazard e projeto de ISA. Os problemas neste exercício referem-se a estas sequências de instrução, e considere que ele é executado em um caminho de dados com pipeline em cinco estágios.
4.13.1 [5] Se não houver forwarding ou detecção de hazard, insira nops para garantir a execução correta. 4.13.2 [10] Repita o Exercício 4.13.1, mas agora use nops somente quando um hazard não puder ser evitado alterando ou rearrumando essas instruções. Você pode considerar que o registrador R7 pode ser usado para manter valores temporários no seu código modificado. 4.13.3 [10] Se o processador tem forwarding, mas nos esquecemos de implementar a unidade de detecção de hazard, o que acontece quando esse código é executado? 4.13.4 [20] Se houver forwarding, para os cinco primeiros ciclos durante a execução desse código, especifique quais sinais são ativados em cada ciclo pelas unidades de detecção de hazard e forwarding na Figura 4.60. 4.13.5 [10] Se não houver forwarding, que novas entradas e sinais de saída precisamos para a unidade de detecção de hazard da Figura 4.60? Usando essa sequência de instruções como exemplo, explique por que cada sinal é necessário. 4.13.6 [20] Para a unidade de detecção de hazard do Exercício 4.13.5, especifique quais sinais de saída ela ativa em cada um dos cinco primeiros ciclos durante a execução desse código. 4.14 Este exercício tem por finalidade ajudá-lo a entender o relacionamento
entre slots de delay, hazards de controle e execução de desvio em um processador com pipeline. Neste exercício, consideramos que o código MIPS a seguir é executado em um processador com um pipeline em cinco estágios, forwarding completo e um previsor de desvio tomado:
4.14.1 [10] Desenhe um diagrama de execução de pipeline para este código, considerando que não existam slots de delay e que os desvios sejam executados no estágio EX. 4.14.2 [10] Repita o Exercício 4.14.1, mas considere que os slots de delay sejam utilizados. No código apresentado, a instrução que vem após o desvio agora é a instrução do slot de delay para esse desvio. 4.14.3 [20] Uma maneira de mover a resolução do desvio para um estágio anterior é não precisar de uma operação da ALU nos desvios condicionais. As instruções de desvio seriam “bez rd,label” e “bnez rd,label”, e haveria desvio se o registrador tivesse e não tivesse um valor 0, respectivamente. Mude esse código para usar essa instrução de desvio em vez de beq. Você pode considerar que o registrador R8 está disponível como um registrador temporário, e que uma instrução tipo R seq (set if equal) pode ser usada. A Seção 4.8 descreve como a rigidez dos hazards de controle pode ser reduzida movendo-se a execução do desvio para o estágio ID. Essa técnica envolve um comparador dedicado no estágio ID, como mostra a Figura 4.62. Porém, essa técnica tem o potencial de aumentar a latência do estágio ID, além de requerer lógica adicional de forwarding e detecção de hazard. 4.14.4 [10] Usando como exemplo a primeira instrução de desvio no código apresentado, descreva a lógica de detecção de hazard necessária para dar suporte à execução do desvio no estágio ID como na Figura 4.62. Que tipo de hazard essa nova lógica deveria detectar? 4.14.5 [10] Para o código apresentado, qual é o ganho de velocidade
alcançado movendo-se a execução do desvio para o estágio ID? Explique sua resposta. No seu cálculo de ganho de velocidade, considere que a comparação adicional no estágio ID não afeta o tempo do ciclo de clock. 4.14.6 [10] Usando como exemplo a primeira instrução de desvio no código apresentado, descreva o suporte para forwarding que precisa ser acrescentado para dar suporte à execução do desvio no estágio ID. Compare a complexidade dessa nova unidade de forwarding com a complexidade da unidade de forwarding existente na Figura 4.62. 4.15 A importância de ter um bom previsor de desvio depende da frequência com que os desvios condicionais são executados. Juntamente com a precisão do previsor de desvio, isso determinará quanto tempo será gasto com stall devido a desvios mal previstos. Neste exercício, considere o desmembramento das instruções dinâmicas em diversas categorias de instrução, como a seguir: Tipo R BEQ JMP LW SW 40%
25%
5%
25% 5%
Além disso, considere as seguintes precisões do previsor de desvio: Sempre tomado Sempre não tomado 2 bits 45%
55%
85%
4.15.1 [10] Os ciclos de stall ocasionados por desvios mal previstos aumentam o CPI. Qual é o CPI extra devido a desvios mal previstos com o previsor sempre tomado? Considere que os resultados do desvio sejam determinados no estágio EX, que não existem hazards de dados e que nenhum slot de delay seja utilizado. 4.15.2 [10] Repita o Exercício 4.15.1 para o previsor “sempre não tomado”. 4.15.3 [10] Repita o Exercício 4.15.1 para o previsor de 2 bits. 4.15.4 [10] Com um previsor de 2 bits, que ganho de velocidade seria alcançado se pudéssemos converter metade das instruções de desvio de um modo que substitua uma instrução de desvio por uma instrução da ALU? Suponha que instruções previstas correta e incorretamente tenham a mesma chance de serem substituídas. 4.15.5 [10] Com um previsor de 2 bits, que ganho de velocidade seria obtido se pudéssemos converter metade das instruções de desvio de um
modo que substituísse cada instrução de desvio por duas instruções da ALU? Suponha que instruções previstas correta e incorretamente tenham a mesma chance de serem substituídas. 4.15.6 [10] Algumas instruções de desvio são muito mais previsíveis do que outras. Se soubermos que 80% de todas as instruções de desvio executadas são desvios loop-back fáceis de prever, que sempre são previstos corretamente, qual é a precisão do previsor de 2 bits nos 20% restantes das instruções de desvio? 4.16 Este exercício examina a precisão de vários previsores de desvios para o seguinte padrão repetitivo (como em um loop) de resultados do desvio: T, NT, T, T, NT
4.16.1 [5] Qual é a precisão dos previsores sempre tomado e sempre não tomado para essa sequência dos resultados do desvio? 4.16.2 [5] Qual é a precisão do previsor de dois bits para os quatro primeiros desvios nesse padrão, supondo que o previsor comece no estado inferior esquerdo da Figura 4.63 (previsão não tomada)? 4.16.3 [10] Qual é a precisão do previsor de dois bits se esse padrão for repetido indefinidamente? 4.16.4 [30] Crie um previsor que alcance uma precisão perfeita se esse padrão for repetido indefinidamente. Seu previsor deverá ser um circuito sequencial com uma saída que oferece uma previsão (1 para tomado, 0 para não tomado) e nenhuma entrada que não seja o clock e o sinal de controle que indica que a instrução é um desvio condicional. 4.16.5 [10] Qual é a precisão do seu previsor do Exercício 4.16.4 se ele receber um padrão repetitivo que é o oposto exato deste? 4.16.6 [20] Repita o Exercício 4.16.4, mas agora o seu previsor deverá ser capaz de, mais cedo ou mais tarde (após um período de aquecimento durante o qual poderá fazer previsões erradas), começar a prever perfeitamente esse padrão e seu oposto. Seu previsor deverá ter uma entrada que lhe diga qual foi o resultado real. Dica: essa entrada permite que seu previsor determine qual dos dois padrões repetitivos ele recebe. 4.17 Este exercício explora como o tratamento de exceção afeta o projeto do pipeline. Os três primeiros problemas neste exercício referem-se às duas instruções a seguir: Instrução 1 BNE R1,R2,Label
Instrução 2 LW R1,0(R1)
4.17.1 [5] Quais exceções cada uma dessas instruções pode disparar? Para cada uma dessas exceções, especifique o estágio do pipeline em que ela é detectada. 4.17.2 [10] Se houver um endereço de handler separado para cada exceção, mostre como a organização do pipeline deve ser mudada para ser capaz de tratar dessa exceção. Você pode considerar que os endereços desses handlers são conhecidos quando o processador é projetado. 4.17.3 [10] Se a segunda instrução dessa tabela for apanhada logo após a instrução da primeira tabela, descreva o que acontece no pipeline quando a primeira instrução causa a primeira exceção que você listou no Exercício 4.17.1. Mostre o diagrama de execução do pipeline do momento em que a primeira instrução é apanhada até o momento em que a primeira instrução do handler de exceção é concluída. 4.17.4 [20] No tratamento de exceção com vetor, a tabela de endereços do handler de exceção está na memória de dados em um endereço conhecido (fixo). Mude o pipeline para implementar esse mecanismo de tratamento de exceção. Repita o Exercício 4.17.3 usando esse pipeline modificado e o tratamento de exceção com vetor. 4.17.5 [15] Queremos simular o tratamento de exceção com vetor (descrito no Exercício 4.17.4) em uma máquina que tem apenas um endereço de handler fixo. Escreva o código que deverá estar nesse endereço fixo. Dica: esse código deverá identificar a exceção, obter o endereço correto da tabela de vetor de exceção e transferir a execução para esse handler. 4.18 Neste exercício, comparamos o desempenho dos processadores de um despacho e processadores de dois despachos, levando em conta as transformações do programa que podem ser feitas para otimizar a execução em dois despachos. Os problemas neste exercício referem-se ao seguinte loop (escrito em C):
Ao escrever código MIPS, considere que as variáveis são mantidas em registradores da seguinte forma, e que todos os registradores, com exceção daqueles indicados como Livre, são usados para manter diversas variáveis,
daqueles indicados como Livre, são usados para manter diversas variáveis, de modo que não podem mais ser usados. i
j
a
b
c
Livre
R5
R6
R1
R2
R3
R10, R11, R12
4.18.1 [10] Traduza esse código C para instruções MIPS. Sua tradução deverá ser direta, sem rearrumar as instruções para conseguir melhor desempenho. 4.18.2 [10] Se o loop sair depois de executar apenas duas iterações, desenhe um diagrama de pipeline para o seu código MIPS do Exercício 4.18.1 executado em um processador com dois despachos mostrado na Figura 4.69. Suponha que o processador tenha previsão de desvio perfeita e possa buscar quaisquer duas instruções (não apenas instruções consecutivas) no mesmo ciclo. 4.18.3 [10] Rearrume o seu código do Exercício 4.18.1 para alcançar o melhor desempenho em um processador de dois despachos estático, da Figura 4.69. 4.18.4 [10] Repita o Exercício 4.18.2, mas desta vez use seu código MIPS do Exercício 4.18.3. 4.18.5 [10] Qual é o ganho de velocidade ao passar de um processador de um despacho para o de dois despachos da Figura 4.69? Use o seu código do Exercício 4.18.1 para um despacho e dois despachos, e considere que 1.000.000 iterações do loop são executadas. Assim como no Exercício 4.18.2, considere que o processador tenha previsões de desvio perfeitas, e que um processador de dois despachos possa buscar duas instruções quaisquer no mesmo ciclo. 4.18.6 [10] Repita o Exercício 4.18.5, mas desta vez considere que, no processador de dois despachos, uma das instruções a serem executadas em um ciclo possa ser de qualquer tipo e a outra uma instrução que não seja de memória. 4.19 Este exercício explora a eficiência de energia e seu relacionamento com o desempenho. Os problemas neste exercício consideram o consumo de energia a seguir para a atividade na Memória de Instrução, Registradores e Memória de Dados. Você pode considerar que os outros componentes do caminho de dados gastam uma quantidade de energia insignificante. I-Mem 1 Leitura de Registrador Escrita de Registrador Leitura de Mem D Escrita de Mem D
140 pJ
70 pJ
60 pJ
140 pJ
120 pJ
Suponha que os componentes no caminho de dados tenham as latências a seguir. Você pode considerar que os outros componentes do caminho de dados têm latência insignificante. I-Mem Controle Registrador de leitura ou escrita 200 ps
150 ps
90 ps
ALU
Leitura ou escrita de Mem D
90 ps
250 ps
4.19.1 [10] Quanta energia é gasta para executar uma instrução ADD em um projeto de ciclo único e no projeto em pipeline com cinco estágios? 4.19.2 [10] Qual é a instrução MIPS no pior caso em termos do consumo de energia, e qual é a energia gasta para executá-la? 4.19.3 [10] Se a redução de energia é fundamental, como você mudaria o projeto em pipeline? Qual é a redução percentual na energia gasta por uma instrução LW após essa mudança? 4.19.4 [10] Qual é o impacto das suas mudanças do Exercício 4.19.3 sobre o desempenho? 4.19.5 [10] Podemos eliminar o sinal de controle MemRead e fazer com que a memória de dados seja lida em cada ciclo, ou seja, podemos ter MemRead = 1 permanentemente. Explique por que o processador ainda funciona corretamente após essa mudança. Qual é o efeito dessa mudança sobre a frequência de clock e consumo de energia? 4.19.6 [10] Se uma unidade ociosa gasta 10% da potência que gastaria se estivesse ativa, qual é a energia gasta pela memória de instrução em cada ciclo? Que porcentagem da energia geral gasta pela memória de instrução essa energia ociosa representa?
Respostas das Seções “Verifique você mesmo” §4.1, página 220: 3 de 5: Controle, Caminho de dados, Memória, Entrada e Saída estão faltando. §4.2, página 222: falso. Elementos de estado disparados na borda tornam a leitura e escrita simultâneas tanto possíveis quanto não ambíguas. §4.3, página 229: I. a. II. c. §4.4, página 240: Sim, Desvio e ALUOp0 são idênticos. Além disso, MemtoReg e RegDst são opostos um do outro. Você não precisa de um inversor; basta usar o outro sinal e inverter a ordem das entradas para o multiplexador!
multiplexador! §4.5, página 251: 1. Stall no resultado lw. 2. Bypassing do primeiro resultado de add escrito em $t1. 3. Nenhum stall ou bypassing é necessário. §4.6, página 261: Afirmações 2 e 4 estão corretas; o restante está incorreto. §4.8, página 285: 1. Previsão não tomada. 2. Previsão tomada. 3. Previsão dinâmica. §4.9, página 290: A primeira instrução, pois ela é executada logicamente antes das outras. §4.10, página 301: 1. Ambos. 2. Ambos. 3. Software. 4. Hardware. 5. Hardware. 6. Hardware. 7. Ambos. 8. Hardware. 9. Ambos. §4.12, página 308: Duas primeiras são falsas e duas últimas são verdadeiras.
Grande e Rápida: Explorando a Hierarquia de Memória O ideal seria ter uma capacidade de memória infinitamente grande, a ponto de qualquer palavra específica […]estar imediatamente disponível. […] Somos[…]forçados a reconhecer a possibilidade de construir uma hierarquia de memórias, cada uma com capacidade maior do que a anterior, mas com acessibilidade menos rápida. A. W. Burks, H. H. Goldstine e J. von Neumann Preliminary Discussion of the Logical Design of an Electronic Computing Instrument, 1946
5.1 Introdução 5.2 Tecnologias de memória 5.3 Princípios básicos de cache 5.4 Medindo e melhorando o desempenho da cache 5.5 Hierarquia de memória estável 5.6 Máquinas virtuais 5.7 Memória virtual 5.8 Uma estrutura comum para hierarquias de memória 5.9 Usando uma máquina de estado finito para controlar uma cache simples 5.10 Paralelismo e hierarquias de memória: coerência de cache 5.11 Vida real: as hierarquias de memória ARM Cortex-A8 e Intel Core i7
5.12 Mais rápido: Bloqueio de cache e multiplicação matricial 5.13 Falácias e armadilhas 5.14 Comentários finais 5.15 Exercícios
Os cinco componentes clássicos de um computador
5.1. Introdução Desde os primeiros dias da computação, os programadores têm desejado quantidades ilimitadas de memória rápida. Os tópicos deste capítulo ajudam os programadores a criar essa ilusão. Antes de vermos como a ilusão é realmente criada, vamos considerar uma analogia simples que ilustra os princípios e mecanismos-chave utilizados.
Suponha que você fosse um estudante fazendo um trabalho sobre os importantes desenvolvimentos históricos no hardware dos computadores. Você está sentado em uma biblioteca examinando uma pilha de livros retirada das estantes. Você descobre que vários computadores importantes, sobre os quais precisa escrever, são descritos nos livros encontrados, mas não há nada sobre o EDSAC. Então, volta às estantes e procura um outro livro. Você encontra um livro sobre os primeiros computadores britânicos, que fala sobre o EDSAC. Com uma boa seleção de livros sobre a mesa à sua frente, existe uma boa probabilidade de que muitos dos tópicos de que precisa possam ser encontrados neles. Com isso, você pode gastar mais do seu tempo apenas usando os livros na mesa sem voltar às estantes. Ter vários livros na mesa economiza seu tempo em comparação a ter apenas um livro e constantemente precisar voltar às estantes para devolvê-lo e apanhar outro. O mesmo princípio nos permite criar a ilusão de uma memória grande que podemos acessar tão rapidamente quanto uma memória muito pequena. Assim como você não precisou acessar todos os livros da biblioteca ao mesmo tempo com igual probabilidade, um programa não acessa todo o seu código ou dados ao mesmo tempo com igual probabilidade. Caso contrário, seria impossível tornar rápida a maioria dos acessos à memória e ainda ter memória grande nos computadores, assim como seria impossível você colocar todos os livros da biblioteca em sua mesa e ainda encontrar rapidamente o que deseja. Esse princípio da localidade sustenta a maneira como você fez seu trabalho na biblioteca e o modo como os programas funcionam. O princípio da localidade diz que os programas acessam uma parte relativamente pequena do seu espaço de endereçamento em qualquer instante do tempo, exatamente como você acessou uma parte bastante pequena da coleção da biblioteca. Há dois tipos diferentes de localidade: ▪ Localidade temporal (localidade no tempo): se um item é referenciado, ele tenderá a ser referenciado novamente em breve. Se você trouxe um livro à mesa para examiná-lo, é provável que precise examiná-lo novamente em breve.
localidade temporal O princípio em que se um local de dados é referenciado, então, ele tenderá a ser referenciado novamente em breve.
▪ Localidade espacial (localidade no espaço): se um item é referenciado, os itens cujos endereços estão próximos tenderão a ser referenciados em breve. Por exemplo, ao trazer o livro sobre os primeiros computadores ingleses para pesquisar sobre o EDSAC, você também percebeu que havia outro livro ao lado dele na estante sobre computadores mecânicos; então, resolveu trazer também esse livro, no qual, mais tarde, encontrou algo útil. Os livros sobre o mesmo assunto são colocados juntos na biblioteca para aumentar a localidade espacial. Veremos como a localidade espacial é usada nas hierarquias de memória um pouco mais adiante neste capítulo.
localidade espacial O princípio da localidade em que, se um local de dados é referenciado, então, os dados com endereços próximos tenderão a ser referenciados em breve. Assim como os acessos aos livros na estante exibem naturalmente a localidade, esta mesma localidade nos programas surge de estruturas de programa simples e naturais. Por exemplo, a maioria dos programas contém loops e, portanto, as instruções e os dados provavelmente são acessados de modo repetitivo, mostrando altas quantidades de localidade temporal. Como, em geral, as instruções são acessadas sequencialmente, os programas mostram alta localidade espacial. Os acessos a dados também exibem uma localidade espacial natural. Por exemplo, os acessos sequenciais aos elementos de um array ou de um registro terão altos índices de localidade espacial. Tiramos vantagem do princípio da localidade implementando a memória de um computador como uma hierarquia de memória. Uma hierarquia de memória consiste em múltiplos níveis de memória com diferentes velocidades e tamanhos. As memórias mais rápidas são mais caras por bit do que as memórias mais lentas e, portanto, são menores.
hierarquia de memória Uma estrutura que usa múltiplos níveis de memórias; conforme a distância para o processador aumenta, o tamanho das memórias e o tempo de acesso também aumentam. A Figura 5.1 mostra que a memória mais rápida está próxima do processador e
a memória mais lenta e barata está abaixo dele. O objetivo é oferecer ao usuário o máximo de memória disponível na tecnologia mais barata, enquanto se fornece acesso à velocidade oferecida pela memória mais rápida.
FIGURA 5.1 A estrutura básica de uma hierarquia de memória. Implementando o sistema de memória como uma hierarquia, o usuário tem a ilusão de uma memória que é tão grande quanto o maior nível da hierarquia, mas pode ser acessada como se fosse totalmente construída com a memória mais rápida. A memória flash substituiu os discos em muitos dispositivos embutidos, e pode levar a um novo nível na hierarquia de armazenamento para computadores de desktop e servidor; veja Seção 5.2.
Da mesma forma, os dados são organizados como uma hierarquia: um nível mais próximo do processador, em geral, é um subconjunto de qualquer nível mais distante, e todos os dados são armazenados no nível mais baixo. Por analogia, os livros em sua mesa formam um subconjunto da biblioteca onde você está trabalhando, que, por sua vez, é um subconjunto de todas as bibliotecas do campus. Além disso, conforme nos afastamos do processador, os níveis levam cada vez mais tempo para serem acessados, exatamente como poderíamos encontrar em uma hierarquia de bibliotecas de campus. Uma hierarquia de memória pode consistir em múltiplos níveis, mas os dados
são copiados apenas entre dois níveis adjacentes ao mesmo tempo, de modo que podemos concentrar nossa atenção em apenas dois níveis. O nível superior — o que está mais perto do processador — é menor e mais rápido (já que usa tecnologia mais cara) do que o nível inferior. A Figura 5.2 mostra que a unidade de informação mínima que pode estar presente ou ausente na hierarquia de dois níveis é denominada um bloco ou uma linha; em nossa analogia da biblioteca, um bloco de informação seria um livro.
FIGURA 5.2 Cada par de níveis na hierarquia de memória pode ser imaginado como tendo um nível superior e um nível inferior. Dentro de cada nível, a unidade de informação que está presente ou não é chamada de um bloco ou uma linha. Em geral, transferimos um bloco inteiro quando copiamos algo entre os níveis.
bloco (ou linha) A unidade mínima de informação que pode estar presente ou ausente em uma cache. Se os dados requisitados pelo processador aparecerem em algum bloco no nível superior, isso é chamado um acerto (análogo a encontrar a informação em um dos livros em sua mesa). Se os dados não forem encontrados no nível superior, a requisição é chamada uma falha. O nível inferior em uma hierarquia é, então, acessado para recuperar o bloco com os dados requisitados. (Continuando com nossa analogia, você vai da sua mesa até as estantes para encontrar o livro desejado.) A taxa de acertos é a fração dos acessos à memória encontrados no nível superior; ela normalmente é usada como uma medida do desempenho da hierarquia de memória. A taxa de falhas (1 – taxa de acertos) é a proporção dos acessos à memória não encontrados no nível superior.
taxa de acertos A proporção dos acessos à memória encontrados em um nível da hierarquia de memória.
taxa de falhas A proporção dos acessos à memória não encontrados em um nível da hierarquia de memória. Como o desempenho é o principal razão de ter uma hierarquia de memória, o tempo para servir acertos e falhas é um aspecto importante. O tempo de acerto é o tempo para acessar o nível superior da hierarquia de memória, que inclui o período para determinar se o acesso é um acerto ou uma falha (ou seja, o tempo necessário para consultar os livros na mesa). A penalidade de falha é o tempo de substituição de um bloco no nível superior pelo bloco correspondente do nível inferior, mais o tempo para transferir esse bloco ao processador (ou, o tempo de apanhar outro livro das estantes e colocá-lo na mesa). Como o nível superior é menor e construído usando partes de memória mais rápidas, o tempo de acerto será muito menor do que o tempo para acessar o próximo nível na hierarquia, que é o principal componente da penalidade de falha. (O tempo para examinar os livros na mesa é muito menor do que o tempo para se levantar e apanhar um
novo livro nas estantes.)
tempo de acerto O tempo necessário para acessar um nível da hierarquia de memória, incluindo o tempo necessário para determinar se o acesso é um acerto ou uma falha.
penalidade de falha O tempo necessário na busca de um bloco de nível inferior para um nível superior da hierarquia de memória, incluindo o tempo para acessar o bloco, transmiti-lo de um nível a outro e inseri-lo no nível que experimentou a falha, e depois passar o bloco a quem o solicitou. Como veremos neste capítulo, os conceitos usados para construir sistemas de memória afetam muitos outros aspectos de um computador, inclusive como o sistema operacional gerencia a memória e a E/S, como os compiladores geram código e mesmo como as aplicações usam o computador. É claro que, como todos os programas gastam muito do seu tempo acessando a memória, o sistema de memória é necessariamente um importante fator para se determinar o desempenho. A confiança nas hierarquias de memória para obter desempenho tem indicado que os programadores (que costumavam pensar na memória como um dispositivo de armazenamento plano e de acesso aleatório) agora precisam entender as hierarquias de memória de modo a alcançarem um bom desempenho. Para mostrar como esse entendimento é importante, mais adiante iremos fornecer alguns exemplos, como na Figura 5.18 e na Seção 5.12, que mostram como dobrar o desempenho da multiplicação matricial. Como os sistemas de memória são essenciais para o desempenho, os projetistas de computadores têm dedicado muita atenção a esses sistemas e desenvolvido sofisticados mecanismos voltados a melhorar o desempenho do sistema de memória. Neste capítulo, veremos as principais ideias conceituais, embora muitas simplificações e abstrações tenham sido usadas no sentido de manter o material praticável em tamanho e complexidade.
Colocando em perspectiva Os programas apresentam localidade temporal (a tendência de reutilizar itens
de dados recentemente acessados) e localidade espacial (a tendência de referenciar itens de dados que estão próximos a outros itens recentemente acessados). As hierarquias de memória tiram proveito da localidade temporal mantendo mais próximos do processador os itens de dados acessados mais recentemente. As hierarquias de memória tiram proveito da localidade espacial movendo blocos consistindo em múltiplas palavras contíguas na memória para níveis superiores na hierarquia. A Figura 5.3 mostra que uma hierarquia de memória usa tecnologias de memória menores e mais rápidas, perto do processador. Portanto, os acessos com acerto no nível mais alto da hierarquia podem ser processados rapidamente. Os acessos com falha vão para os níveis mais baixos da hierarquia, que são maiores, porém mais lentos. Se a taxa de acertos for bastante alta, a hierarquia de memória terá um tempo de acesso efetivo, próximo ao tempo de acesso do nível mais alto (e mais rápido) e um tamanho igual ao do nível mais baixo (e maior).
FIGURA 5.3 Este diagrama mostra a estrutura de uma hierarquia de memória: conforme a distância entre ela e o
processador aumenta, o tamanho também aumenta. Essa estrutura, com os mecanismos de operação apropriados, permite que o processador tenha um tempo de acesso determinado principalmente pelo nível 1 da hierarquia e ainda tenha uma memória tão grande quanto o nível n. Manter essa ilusão é o assunto deste capítulo. Embora o disco local normalmente seja a parte inferior da hierarquia, alguns sistemas usam fita ou um servidor de arquivos numa rede local como os próximos níveis da hierarquia.
Na maioria dos sistemas, a memória é uma hierarquia verdadeira, o que significa que os dados não podem estar presentes no nível i a menos que também estejam presentes no nível i + 1.
Verifique você mesmo Quais das seguintes afirmações normalmente são verdadeiras? 1. As hierarquias de memória tiram proveito da localidade temporal. 2. Em uma leitura, o valor retornado depende de quais blocos estão na cache. 3. A maioria do custo da hierarquia de memória está no nível mais alto. 4. A maioria da capacidade da hierarquia de memória está no nível mais baixo.
5.2. Tecnologias de memória Existem quatro tecnologias principais usadas atualmente nas hierarquias de memória. A memória principal é implementada a partir da DRAM (Dynamic Random Access Memory), enquanto os níveis mais próximos do processador (caches) usam SRAM (Static Random Access Memory). A DRAM custa menos por bit do que a SRAM, embora seja substancialmente mais lenta. A diferença no preço aumenta porque a DRAM usa muito menos área por bit de memória, e portanto as DRAMs possuem mais capacidade pela mesma quantidade de silício; a diferença na velocidade vem de vários fatores descritos na Seção B.9 do Apêndice B. A terceira tecnologia é a memória flash. Essa memória não volátil é a memória secundária nos Dispositivos Móveis Pessoais. A quarta tecnologia, usada para implementar o nível maior e mais lento na hierarquia nos servidores, é o disco magnético. O tempo de acesso e o preço por bit variam bastante entre essas tecnologias, como mostra a tabela a seguir, usando valores típicos para
2012: Tecnologia de memória
Tempo de acesso típico US$ por GiB em 2012
Memória semicondutora SRAM
0,5–2,5 ns
US$500–US$1000
Memória semicondutora DRAM
50–70 ns
US$10–US$20
Memória flash semicondutora
5.000–50.000 ns
US$0,75–US$1,00
Disco magnético
5.000.000–20.000.000 ns
US$0,05–US$0,10
Descrevemos cada tecnologia de memória no restante desta seção.
Tecnologia de SRAM SRAMs são simplesmente circuitos integrados compostos de arrays de memória com (geralmente) uma única porta de acesso que pode oferecer uma leitura ou uma escrita. SRAMs possuem um tempo de acesso fixo a qualquer dado, embora os tempos de acesso para leitura e escrita possam ser diferentes. SRAMs não precisam de refresh e, portanto, o tempo de acesso é muito próximo do tempo de ciclo. Elas normalmente utilizam de seis a oito transistores por bit para impedir que a informação seja alterada quando for lida. A SRAM precisa de um mínimo de energia para reter a carga no modo standby. No passado, a maioria dos sistemas PC e servidor usava chips SRAM separados para suas caches primária, secundária ou mesmo terciária. Hoje, graças à Lei de Moore, todos os níveis de caches são integrados no chip do processador, de modo que o mercado para chips SRAM separados quase desapareceu.
Tecnologia DRAM Em uma SRAM, desde que haja energia aplicada, o valor pode ser mantido indefinidamente. Em uma RAM dinâmica (DRAM), o valor mantido em uma célula é armazenado como uma carga em um capacitor. Um único transistor é então utilizado para acessar essa carga armazenada, seja para ler o valor ou para modificar a carga lá armazenada. Como as DRAMs usam apenas um único transistor por bit de armazenamento, elas são muito mais densas e mais baratas por bit do que a SRAM. Como as DRAMs armazenam a carga em um capacitor, esta não pode ser mantida indefinidamente, e precisa ser renovada periodicamente. É por isso que essa estrutura de memória é denominada dinâmica, ao contrário do armazenamento estático em uma célula de SRAM. Para renovar uma célula, simplesmente lemos seu conteúdo e o escrevemos de volta. A carga pode ser retida por vários milissegundos. Se cada bit tivesse que ser lido da DRAM e depois escrito de volta individualmente, estaríamos constantemente renovando a DRAM, sem que restasse tempo para acessá-la. Felizmente, as DRAMs utilizam uma estrutura de decodificação de dois níveis, e isso nos permite renovar uma linha inteira (que armazena uma linha de words) com um ciclo de leitura, seguido imediatamente por um ciclo de escrita. A Figura 5.4 mostra a organização interna de uma DRAM e a Figura 5.5 mostra como a densidade, o custo e o tempo de acesso das DRAMs mudaram
com o passar dos anos.
FIGURA 5.4 Organização interna de uma DRAM. As DRAMs modernas são organizadas em bancos, normalmente quatro para a DDR3. Cada banco consiste em uma série de linhas. O envio de um comando PRE (pré-carga) abre ou fecha um banco. Um endereço de linha é enviado com um comando Ativar, que faz com que a linha seja transferida para um buffer. Quando a linha estiver no buffer, ela poderá ser transferida por endereços de coluna sucessivos por qualquer que seja a largura da DRAM (normalmente 4, 8 ou 16 bits na DDR3), ou especificando uma transferência em bloco e o endereço inicial. Cada comando, bem como as transferências em bloco, é sincronizado com um clock.
FIGURA 5.5 Tamanho das DRAM aumentado por múltiplos de quatro, aproximadamente, uma vez a cada três anos até 1996, e depois disso bem mais lentamente. As melhorias no tempo de acesso têm sido mais lentas, porém contínuas, e o custo acompanha aproximadamente as melhorias na densidade, embora o custo geralmente seja afetado por outras questões, como disponibilidade e demanda. O custo por gibibyte não está ajustado pela inflação.
A organização de linha que ajuda na renovação também ajuda com o desempenho. Para melhorar o desempenho, as DRAMs colocam as linhas em um buffer visando o acesso repetido. O buffer atua como uma SRAM; alterando o endereço, bits aleatórios podem ser acessados no buffer até o acesso da próxima linha. Essa capacidade melhora significativamente o tempo de acesso, pois o tempo de acesso aos bits na linha é muito menor. Tornar o chip mais largo também melhora a largura de banda de memória do chip. Quando a linha está no buffer, ela pode ser transferida para endereços sucessivos qualquer que seja a largura da DRAM (normalmente, 4, 8 ou 16 bits), ou especificando uma transferência em bloco e o endereço inicial dentro do buffer. Para melhorar ainda mais a interface com os processadores, as DRAMs acrescentaram clocks e são devidamente chamadas de DRAMs síncronas ou SDRAMs. A vantagem das SDRAMs é que o uso de um clock elimina o tempo de sincronização entre a memória e o processador. A vantagem na velocidade das DRAMs síncronas vem da capacidade de transferir os bits na rajada sem ter que especificar bits de endereço adicionais. Em vez disso, o clock transfere os
bits sucessivos de uma só vez. A versão mais veloz é denominada SDRAM DDR (Double Data Rate — taxa de dados dupla). O nome indica que as transferências de dados são realizadas nas bordas de subida e descida do clock, obtendo assim o dobro da largura de banda que você poderia esperar com base na taxa de clock e largura dos dados. A versão mais recente dessa tecnologia se chama DDR4. Uma DRAM DDR4-3200 pode realizar 3200 milhões de transferências por segundo, o que significa que tem um clock de 1600 MHz. Para sustentar tanta largura de banda, é preciso uma organização inteligente dentro da DRAM. Em vez de simplesmente um buffer de linha mais rápido, a DRAM pode ser organizada internamente para ler ou escrever, a partir de vários bancos, com cada um tendo seu próprio buffer de linha. O envio de um endereço a vários bancos permite que todos eles leiam ou escrevam simultaneamente. Por exemplo, com quatro bancos, há apenas um tempo de acesso e depois os acessos fazem o rodízio entre os quatro bancos, para fornecer quatro vezes a largura de banda. Esse esquema de acesso em forma de rodízio é chamado de intercalação de endereço. Embora os Personal Mobile Devices como o iPad (Capítulo 1) usem DRAMs individuais, a memória para os servidores normalmente é vendida em pequenas placas chamadas módulos de memória dual em linha (DIMMs — Dual Inline Memory Modules). As DIMMs normalmente contêm de 4 a 16 DRAMs, e normalmente são organizadas para que tenham 8 bytes de largura para os sistemas servidores. Uma DIMM usando SDRAMs DDR4-3200 poderia transferir a 8 × 3200 = 25.600 megabytes por segundo. Essas DIMMs recebem o nome de sua largura de banda: PC25600. Como uma DIMM pode ter tantos chips de DRAM que somente uma parte deles seja usada para uma transferência em particular, precisamos de um termo para nos referir ao subconjunto de chips em uma DIMM que compartilhe linhas de endereço comuns. Para evitar confusão com os nomes de DRAM internos das linhas e bancos, usamos o termo fileira de memória para esse subconjunto de chips em uma DIMM.
Detalhamento Uma forma de medir o desempenho de um sistema de memória por trás das caches é o benchmark Stream (McCalpin, 1995). Ele mede o desempenho de operações de vetor longas. Elas não possuem localidade temporal e acessam arrays que não são maiores do que a cache do computador sendo testado.
Memória flash
Memória flash A memória flash é um tipo de memória somente de leitura programável e apagável eletricamente (EEPROM). Diferente dos discos e da DRAM, mas como outras tecnologias de EEPROM, as escritas podem desgastar os bits da memória flash. Para lidar com esses limites, a maior parte dos produtos flash inclui um controlador para espalhar as escritas, remapeando blocos que foram escritos muitas vezes, para blocos menos “pisados”. Essa técnica é chamada de nivelamento do desgaste. Com o nivelamento do desgaste, os dispositivos móveis pessoais provavelmente não excederão os limites de escrita na memória flash. Esse nivelamento do desgaste reduz o desempenho em potencial da memória flash, mas é necessário para que um software de nível mais alto não tenha que monitorar o desgaste do bloco. Os controladores flash que realizam o nivelamento do desgaste também podem melhorar o aproveitamento, retirando do mapeamento as células que foram manufaturadas com falha.
Memória em disco Como mostra a Figura 5.6, um disco rígido magnético consiste em um conjunto de placas, que giram em um eixo a 5400 a 15.000 rotações por minuto. As placas de metal são cobertas com um material de gravação magnético nos dois lados, semelhante ao material encontrado em uma fita cassete ou de vídeo. Para ler e gravar informações em um disco rígido, um braço móvel contendo uma pequena bobina eletromagnética, chamada cabeça de leitura/gravação, é posicionado bem próximo a cada superfície. A unidade inteira fica permanentemente lacrada para controlar o ambiente dentro dela, que, por sua vez, permite que as cabeças do disco fiquem muito mais próximas da superfície do disco.
FIGURA 5.6 Um disco mostrando 10 placas e cabeças de leitura/gravação. O diâmetro dos discos atualmente é de 2,5 ou 3,5 polegadas, e normalmente existem apenas uma ou duas placas por unidade.
Cada superfície do disco é dividida em círculos concêntricos, chamados de
trilhas. Normalmente, existem dezenas de milhares de trilhas por superfície. Cada trilha, por sua vez, é dividida em setores que contêm as informações; cada trilha pode ter milhares de setores. Os setores normalmente armazenam 512 a 4096 bytes. A sequência gravada no meio magnético é um número de setor, uma lacuna, a informação sobre esse setor, incluindo o código de correção de erro (Seção 5.5), uma lacuna, o número do próximo setor, e assim por diante.
trilha Um de milhares de círculos concêntricos que compõem a superfície de um disco magnético.
setor Um dos segmentos que compõem uma trilha em um disco magnético; um setor é a menor quantidade de informação que pode ser lida ou gravada em um disco. As cabeças do disco para cada superfície são conectadas e se movem em conjunto, de modo que cada cabeça está posicionada sobre a mesma trilha de cada superfície. O termo cilindro é usado para se referir a todas as trilhas sob as cabeças em determinado ponto, para todas as superfícies. Para acessar os dados, o sistema operacional precisa direcionar o disco através de um processo em três estágios. O primeiro passo é posicionar a cabeça sobre a trilha correta. Essa operação é chamada de busca, e o tempo para mover a cabeça até a trilha desejada é chamado de tempo de busca.
busca O processo de posicionar uma cabeça de leitura/gravação sobre a trilha apropriada em um disco. Os fabricantes de disco informam os tempos de busca mínimo, máximo e médio em seus manuais. Os dois primeiros são fáceis de medir, mas o tempo médio é aberto a várias interpretações, pois depende da distância da busca. A indústria calcula o tempo de busca médio como a soma do tempo para todas as buscas possíveis, dividido pelo número de buscas possíveis. Os tempos de busca médios normalmente são anunciados como algo entre 3 e 13 ms, mas,
dependendo da aplicação e do escalonamento de solicitações de disco, o tempo de busca médio real pode ser de apenas 25% a 33% do número anunciado, devido à localidade das referências ao disco. Essa localidade surge tanto devido a acessos sucessivos ao mesmo arquivo quanto porque o sistema operacional tenta escalonar esses acessos para que sejam feitos juntos. Quando a cabeça tiver alcançado a trilha correta, temos que esperar até que o setor desejado gire sob a cabeça de leitura/escrita. Esse tempo é chamado de latência rotacional ou atraso rotacional. A latência média até a informação desejada é a metade da circunferência da trilha no disco. Os discos giram a 5400 RPM até 15.000 RPM. A latência rotacional média a 5400 RPM é
latência rotacional Também chamado atraso rotacional. O tempo exigido para que o setor desejado de um disco gire sob a cabeça de leitura/gravação; normalmente considerado como metade do tempo de rotação. O último componente de um acesso ao disco, o tempo de transferência, é o tempo para transferir um bloco de bits. O tempo de transferência é uma função do tamanho do setor, da velocidade de rotação e da densidade de gravação de uma trilha. As taxas de transferência em 2012 estavam entre 100 e 200 MB/segundo. Uma complicação é que a maioria dos controladores de disco possui uma cache embutida que armazena os setores à medida que passam por eles; as taxas de transferência da cache normalmente são maiores, e eram de até 750 MB/segundo (6 Gbit/segundo) em 2012. Infelizmente, o local onde os números de bloco estão localizados não é mais algo intuitivo. As suposições do modelo setor-trilha-cilindro, indicadas anteriormente, são que os blocos mais próximos estão na mesma trilha, que os blocos no mesmo cilindro levam menos tempo para serem acessados, pois não
há tempo de busca, e que algumas trilhas estão mais próximas do que outras. O motivo para a mudança foi o aumento do nível das interfaces de disco. Para agilizar as transferências sequenciais, essas interfaces de nível mais alto organizam os discos mais como fitas do que como dispositivos de acesso aleatório. Os blocos lógicos são ordenados em um padrão de serpentina por uma única superfície, tentando capturar todos os setores que são gravados na mesma densidade de bit e obter o máximo de desempenho. Logo, blocos sequenciais podem estar posicionados em diferentes trilhas. Resumindo, as duas diferenças principais entre os discos magnéticos e as tecnologias de memória de semicondutor são que os discos possuem um tempo de acesso mais lento, pois são dispositivos mecânicos — a memória flash é 1000 vezes mais rápida e a DRAM é 100.000 vezes mais rápida —, embora sejam mais baratos por bit, pois possuem uma capacidade de armazenamento muito alta e um custo moderado — o disco é de 10 a 100 vezes mais barato. Os discos magnéticos são não voláteis, como a memória flash, mas, diferente dela, não há o problema de desgaste pela escrita. Porém, a memória flash é muito mais resistente e, portanto, combina muito mais com os impactos inerentes aos dispositivos móveis pessoais.
5.3. Princípios básicos de cache Cache: um lugar seguro para esconder ou guardar coisas. Webster’s New World Dictionary of the American Language, Third College Edition (1988) Em nosso exemplo da biblioteca, a mesa servia como uma cache — um lugar seguro para guardar coisas (livros) que precisávamos examinar. Cache foi o nome escolhido para representar o nível da hierarquia de memória entre o processador e a memória principal no primeiro computador comercial a ter esse nível extra. As memórias no caminho de dados, no Capítulo 4, são simplesmente substituídas por caches. Hoje, embora permaneça o uso dominante da palavra cache, o termo também é usado para referenciar qualquer armazenamento usado para tirar proveito da localidade de acesso. As caches apareceram inicialmente nos computadores de pesquisa no início da década de 1960 e nos computadores
de produção mais tarde nessa mesma década; todo computador de uso geral construído hoje, dos servidores aos processadores embutidos de baixa capacidade, possui caches. Nesta seção, começaremos a ver uma cache muito simples na qual cada requisição do processador é uma palavra e os blocos também consistem em uma única palavra. (Os leitores que já estão familiarizados com os fundamentos de cache podem pular para a Seção 5.4.) A Figura 5.7 mostra essa cache simples, antes e depois de requisitar um item de dados que não está inicialmente na cache. Antes de requisitar, a cache contém uma coleção de referências recentes, X1, X2,… Xn-1, e o processador requisita uma palavra Xn que não está na cache. Essa requisição resulta em uma falha, e a palavra Xn é trazida da memória para a cache.
FIGURA 5.7 A cache, imediatamente antes e após uma referência a uma palavra Xn que não está inicialmente na cache. Essa referência causa uma falha que força a cache a buscar Xn na memória e inseri-la na cache.
Olhando o cenário na Figura 5.7, surgem duas perguntas a serem respondidas: como sabemos se o item de dados está na cache? Além disso, se estiver, como encontrá-lo? As respostas a essas duas questões estão relacionadas. Se cada palavra pode ficar exatamente em um lugar na cache, então, é fácil encontrar a palavra se ela estiver na cache. A maneira mais simples de atribuir um local na cache para cada palavra da memória é atribuir um local na cache baseado no endereço da palavra na memória. Essa estrutura de cache é chamada de mapeamento direto, já que cada local da memória é mapeado diretamente para um local exato na cache. O mapeamento típico entre endereços e locais de cache para uma cache diretamente mapeada é simples. Por exemplo, quase todas as caches diretamente mapeadas usam o mapeamento a seguir para localizar um bloco:
cache de mapeamento direto Uma estrutura de cache em que cada local da memória é mapeado exatamente para um local na cache. Se o número de entradas na cache for uma potência de dois, então, o módulo pode ser calculado simplesmente usando os log2 bits menos significativos (tamanho da cache em blocos) do endereço. Assim, a cache de 8 blocos usa os três bits menos significativos (8 = 23) do endereço do bloco. Por exemplo, a Figura 5.8 mostra como os endereços de memória entre 1dec (00001bin) e 29dec (11101bin) são mapeados para as posições 1dec (001bin) e 5dec (101bin) em uma cache diretamente mapeada de oito words.
FIGURA 5.8 Uma cache diretamente mapeada com oito entradas mostrando os endereços das palavras de memória entre 0 e 31 que mapeiam para os mesmos locais de cache. Como há oito palavras na cache, um endereço X é mapeado para a palavra de cache X, módulo 8. Ou seja, os log2(8) = 3 bits menos significativos são usados como o índice da cache. Assim, os endereços 00001bin, 01001bin, 10001bin e 11001bin são todos mapeados para a entrada 001bin da cache, enquanto os endereços 00101bin, 01101bin, 10101bin e 11101bin são todos mapeados para a entrada 101bin da cache.
Como cada local da cache pode armazenar o conteúdo de diversos locais diferentes da memória, como podemos saber se os dados na cache correspondem a uma palavra requisitada? Ou seja, como sabemos se uma palavra requisitada está na cache ou não? Respondemos a essa pergunta incluindo um conjunto de tags na cache. As tags contêm as informações de endereço necessárias para identificar se uma palavra na cache corresponde à palavra requisitada. A tag precisa apenas conter a parte superior do endereço, correspondente aos bits que não são usados como índice para a cache. Por exemplo, na Figura 5.8, precisamos apenas ter os dois bits mais significativos dos cinco bits de endereço
na tag, já que o campo índice com os três bits menos significativos do endereço seleciona o bloco. Os arquitetos omitem os bits de índice porque eles são redundantes, uma vez que, por definição, o campo índice de qualquer endereço de um bloco de cache precisa ser o número daquele bloco.
tag Um campo em uma tabela usado para uma hierarquia de memória que contém as informações de endereço necessárias para identificar se o bloco associado na hierarquia corresponde a uma palavra requisitada. Também precisamos de uma maneira de reconhecer se um bloco de cache não possui informações válidas. Por exemplo, quando um processador é iniciado, a cache não tem dados válidos, e os campos de tag não terão significado. Mesmo após executar muitas instruções, algumas entradas de cache podem ainda estar vazias, como na Figura 5.7. Portanto, precisamos saber se a tag deve ser ignorada para essas entradas. O método mais comum é incluir um bit de validade indicando se uma entrada contém um endereço válido. Se o bit não estiver marcado, não pode haver uma correspondência para esse bloco.
bit de validade Um campo nas tabelas de uma hierarquia de memória que indica que o bloco associado na hierarquia contém dados válidos. No restante desta seção, vamos nos concentrar em explicar como uma cache trata das leituras. Em geral, a manipulação de leituras é um pouco mais simples do que a manipulação de escritas, já que as leituras não precisam mudar o conteúdo da cache. Após vermos os aspectos básicos de como as leituras funcionam e como as falhas de cache podem ser tratadas, examinaremos os projetos de cache para computadores reais e detalharemos como essas caches manipulam as escritas.
Colocando em perspectiva
O caching talvez seja o exemplo mais importante da grande ideia da predição. Ele conta com o princípio da localidade para tentar encontrar os dados desejados nos níveis mais altos da hierarquia de memória, e oferece mecanismos para garantir que, quando a predição for errada, ela encontra e usa os dados apropriados dos níveis mais baixos da hierarquia da memória. As taxas de acerto da predição de cache nos computadores modernos, normalmente, são mais altas do que 95% (Figura 5.47).
Acessando uma cache A seguir, vemos uma sequência de nove referências da memória a uma cache vazia de oito blocos, incluindo a ação para cada referência. A Figura 5.9 mostra como o conteúdo da cache muda em cada falha. Como há oito blocos na cache, os três bits menos significativos de um endereço fornecem o número do bloco:
FIGURA 5.9 O conteúdo da cache é mostrado para cada requisição de referência que falha, com os campos índice e tag mostrados em binário para a sequência de endereços no início desse tópico. A cache inicialmente está vazia, com todos os bits de validade (entrada V da cache) inativos (N). O processador requisita os seguintes endereços: 10110bin (falha), 11010bin (falha), 10110bin (acerto), 11010bin (acerto), 10000bin (falha), 00011bin (falha), 10000bin (acerto) e 10010bin (falha). As figuras mostram o conteúdo da cache após cada falha na sequência ter sido tratada. Quando o endereço 10010bin (18) é referenciado, a entrada para o endereço 11010bin (26) precisa ser substituída, e uma referência a 11010bin causará uma falha subsequente. O campo tag conterá apenas a parte superior do endereço. O endereço completo de uma palavra contida no bloco de cache i com o campo tag j para essa cache é j × 8 + i ou, de forma equivalente, a concatenação do campo tag j e o campo índice i. Por exemplo, na cache f anterior, o índice 010bin possui tag 10bin
e corresponde ao endereço 10010bin.
Endereço decimal da referência
Endereço binário da referência
Acerto ou falha na cache
Bloco de cache atribuído (onde foi encontrado ou inserido)
22
10110bin
falha (5.6b)
(10110bin mod 8) = 110bin
26
11010bin
falha (5.6c)
(11010bin mod 8) = 010bin
22
10110bin
Acerto
(10110bin mod 8) = 110bin
26
11010bin
Acerto
(11010bin mod 8) = 010bin
16
10000bin
falha (5.6d)
(10000bin mod 8) = 000bin
3
00011bin
falha (5.6e)
(00011bin mod 8) = 011bin
16
10000bin
Acerto
(10000bin mod 8) = 000bin
18
10010bin
falha (5.6f)
(10010bin mod 8) = 010bin
16
10000bin
Acerto
(10000bin mod 8) = 000bin
Como a cache está vazia, várias das primeiras referências são falhas; a legenda da Figura 5.9 descreve as ações de cada referência à memória. Na oitava referência, temos demandas em conflito para um bloco. A palavra no endereço 18 (10010bin) deve ser trazida para o bloco de cache 2 (010bin). Logo, ela precisa substituir a palavra no endereço 26 (11010bin), que já está no bloco de cache 2 (010bin). Esse comportamento permite que uma cache tire proveito da localidade temporal: palavras recentemente acessadas substituem palavras menos referenciadas recentemente. Essa situação é análoga a precisar de um livro da estante e não ter mais espaço na mesa para colocá-lo — algum livro que já esteja na sua mesa precisa ser devolvido à estante. Em uma cache diretamente mapeada, há apenas um lugar para colocar o item recém--requisitado e, portanto, apenas uma escolha do que substituir. Agora, sabemos onde olhar na cache para cada endereço possível: os bits menos significativos de um endereço podem ser usados para encontrar a entrada de cache única para a qual o endereço poderia ser mapeado. A Figura 5.10 mostra como um endereço referenciado é dividido em:
FIGURA 5.10 Para esta cache, a parte inferior do endereço é usada para selecionar uma entrada de cache consistindo em uma palavra de dados e uma tag. Essa cache mantém 1024 palavras ou 4 KiB. Consideramos endereços de 32 bits neste capítulo. A tag da cache é comparada com a parte superior do endereço para determinar se a entrada na cache corresponde ao endereço requisitado. Como a cache tem 210 (ou 1024) palavras e um tamanho de bloco de 1 palavra, 10 bits são usados para indexar a cache, deixando 32 – 10 – 2 = 20 bits para serem comparados com a tag. Se a tag e os 20 bits superiores do endereço forem iguais e o bit de validade estiver ligado, então, a requisição é um acerto na cache e a palavra é fornecida para o processador. Caso contrário, ocorre uma falha.
▪ Um campo tag, usado para ser comparado com o valor do campo tag da cache; ▪ Um índice de cache, usado para selecionar o bloco. O índice de um bloco de cache, juntamente com o conteúdo da tag desse bloco, especifica de modo único o endereço de memória da palavra contida no bloco de cache. Como o campo índice é usado como um endereço para acessar a cache e como um campo de n bits possui 2n valores, o número total de entradas em uma cache diretamente mapeada será uma potência de dois. Na arquitetura MIPS, uma vez que as palavras são alinhadas como múltiplos de 4 bytes, os dois bits menos significativos de cada endereço especificam um byte dentro de uma palavra e, portanto, são ignorados ao selecionar uma palavra no bloco. O número total de bits necessários para uma cache é uma função do tamanho da cache e do tamanho do endereço, pois a cache inclui o armazenamento para os dados e as tags. O tamanho do bloco mencionado anteriormente era de uma palavra, mas normalmente é de várias palavras. Para as situações a seguir: ▪ Endereços em bytes de 32 bits. ▪ Uma cache diretamente mapeada. ▪ O tamanho da cache é 2n blocos, de modo que n bits são usados para o índice. ▪ O tamanho do bloco é 2m palavras (2m+2 bytes), de modo que m bits são usados para a palavra dentro do bloco, e dois bits são usados para a parte de byte do endereço o tamanho do campo de tag é
O número total de bits em uma cache diretamente mapeada é
Como o tamanho do bloco é 2m palavras (2m+5 bits) e precisamos de 1 bit para o campo de validade, o número de bits nessa cache é
Embora esse seja o tamanho real em bits, a convenção de nomeação é excluir
o tamanho da tag e do campo de validade e contar apenas o tamanho dos dados. Assim, a cache na Figura 5.10 é chamada de cache de 4 KiB.
Bits em uma cache Exemplo Quantos bits no total são necessários para uma cache diretamente mapeada com 16 KiB de dados e blocos de 4 palavras, considerando um endereço de 32 bits?
Resposta Sabemos que 16 KiB são 4096 (212) palavras. Com um tamanho de bloco de 4 palavras (22), há 1024 (210) blocos. Cada bloco possui 4 × 32, ou 128 bits de dados mais uma tag, que é 32 – 10 – 2 – 2 bits, mais um bit de validade. Portanto, o tamanho de cache total é
ou 18,4 KiB para uma cache de 16 KiB.. Para essa cache, o número total de bits na cache é aproximadamente 1,15 vezes o necessário apenas para o armazenamento dos dados.
Mapeando um endereço para um bloco de cache multipalavra Exemplo Considere uma cache com 64 blocos e um tamanho de bloco de 16 bytes. Para qual número de bloco o endereço em bytes 1200 é mapeado?
Resposta A fórmula foi vista no início da Seção 5.2. O bloco é dado por
Em que o endereço do bloco é
Observe que esse endereço de bloco é o bloco contendo todos os endereços entre
e
Portanto, com 16 bytes por bloco, o endereço em bytes 1200 é o endereço de bloco
que é mapeado para o número de bloco de cache (75 módulo 64) = 11. Na verdade, esse bloco mapeia todos os endereços entre 1200 e 1215. Blocos maiores exploram a localidade espacial para diminuir as taxas de falhas. Como mostra a Figura 5.11, aumentar o tamanho de bloco normalmente diminui a taxa de falhas. A taxa de falhas pode subir posteriormente se o
tamanho de bloco se tornar uma fração significativa do tamanho de cache, uma vez que o número de blocos que pode ser armazenado na cache se tornará pequeno e haverá uma grande competição entre esses blocos. Como resultado, um bloco será retirado da cache antes que muitas de suas palavras sejam acessadas. Explicando de outra forma: a localidade espacial entre as palavras em um bloco diminui com um bloco muito grande; por conseguinte, os benefícios na taxa de falhas se tornam menores.
FIGURA 5.11 Taxa de falhas versus tamanho de bloco. Note que a taxa de falhas realmente sobe se o tamanho de bloco for muito grande em relação ao tamanho da cache. Cada linha representa uma cache de tamanho diferente. (Esta figura é independente da associatividade, que será discutida em breve.) Infelizmente, os traces do SPEC CPU2000 levariam tempo demais se o tamanho de bloco fosse incluído; portanto, esses dados são baseados no SPEC92.
Um problema mais sério associado ao aumento do tamanho de bloco é que o custo de uma falha aumenta. A penalidade de falha é determinada pelo tempo necessário para buscar o bloco do próximo nível mais baixo na hierarquia e carregá-lo na cache. O tempo para buscar o bloco possui duas partes: a latência até a primeira palavra e o tempo de transferência para o restante do bloco. Claramente, a menos que mudemos o sistema de memória, o tempo de
transferência — e, portanto, a penalidade de falha — provavelmente aumentará conforme o tamanho de bloco aumenta. Além disso, o aumento na taxa de falhas começa a reduzir conforme os blocos se tornam maiores. O resultado é que o aumento na penalidade de falha suplanta o decréscimo na taxa de falhas para grandes blocos, diminuindo, assim, o desempenho da cache. Naturalmente, se projetarmos a memória para transferir blocos maiores de forma mais eficiente, poderemos aumentar o tamanho do bloco e obter mais melhorias no desempenho da cache. Discutiremos esse assunto na próxima seção.
Detalhamento Embora seja difícil fazer algo sobre o componente de latência mais longo da penalidade de falha para blocos grandes, podemos ser capazes de ocultar um pouco do tempo de transferência, de modo que a penalidade de falha seja efetivamente menor. O método mais simples de fazer isso, chamado reinício precoce, é simplesmente retomar a execução assim que a palavra requisitada do bloco seja retornada, em vez de esperar o bloco inteiro. Muitos processadores usam essa técnica para acesso a instruções, que é onde ela funciona melhor. Como os acessos a instruções são extremamente sequenciais, se o sistema de memória puder entregar uma palavra a cada ciclo de clock, o processador poderá ser capaz de reiniciar sua operação quando a palavra requisitada for retornada, com o sistema de memória entregando novas palavras de instrução em tempo. Essa técnica normalmente é menos eficaz para caches de dados porque é provável que as palavras sejam requisitadas do bloco de uma maneira menos previsível; além disso, a probabilidade de que o processador precise de outra palavra de um bloco de cache diferente antes que a transferência seja concluída é alta. Se o processador não puder acessar a cache de dados porque uma transferência está em andamento, então, ele precisará sofrer stall. Um esquema ainda mais sofisticado é organizar a memória de modo que a palavra requisitada seja transferida da memória para a cache primeiro. O restante do bloco, então, é transferido, começando com o endereço após a palavra requisitada e retornando para o início do bloco. Essa técnica, chamada palavra requisitada primeiro, ou palavra crítica primeiro, pode ser um pouco mais rápida do que o reinício precoce, mas ela é limitada pelas mesmas propriedades que limitam o reinício precoce.
Tratando falhas de cache
Tratando falhas de cache Antes de olharmos a cache de um sistema real, vamos ver como a unidade de controle lida com as falhas de cache. (Descrevemos um controlador de cache na Seção 5.9). A unidade de controle precisa detectar uma falha de cache e processá-la buscando os dados requisitados da memória (ou, como veremos, de uma cache de nível inferior). Se a cache reportar um acerto, o computador continua usando os dados como se nada tivesse acontecido.
falha de cache Uma requisição de dados da cache que não pode ser atendida porque os dados não estão presentes na cache. Modificar o controle de um processador para tratar um acerto é fácil; as falhas, no entanto, exigem um trabalho maior. O tratamento da falha de cache é feito com a unidade de controle do processador e com um controlador separado que inicia o acesso à memória e preenche novamente a cache. O processamento de uma falha de cache cria um stall semelhante aos stalls de pipeline (Capítulo 4), ao contrário de uma interrupção, que exigiria salvar o estado de todos os registradores. Para uma falha de cache, podemos fazer um stall no processador inteiro, basicamente congelando o conteúdo dos registradores temporários e visíveis ao programador, enquanto esperamos a memória. Processadores fora de ordem mais sofisticados podem permitir a execução de instruções enquanto se espera por uma falha de cache, mas vamos considerar nesta seção os processadores em ordem, que fazem um stall nas perdas de cache. Vejamos um pouco mais de perto como as falhas de instrução são tratadas; o mesmo método pode ser facilmente estendido para tratar falhas de dados. Se um acesso à instrução resultar em uma falha, o conteúdo do registrador de instrução será inválido. Para colocar a instrução correta na cache, precisamos ser capazes de instruir o nível inferior na hierarquia de memória ao realizar uma leitura. Como o contador do programa é incrementado no primeiro ciclo de clock da execução, o endereço da instrução que gera uma falha de cache de instruções é igual ao valor do contador de programa menos 4. Uma vez que tenhamos o endereço, precisamos instruir a memória principal a realizar uma leitura. Esperamos a memória responder (já que o acesso levará vários ciclos) e, então, escrevemos as palavras com a instrução desejada na cache. Agora, podemos definir as etapas a serem realizadas em uma falha de cache
de instruções: 1. Enviar o valor do PC original (PC atual – 4) para a memória. 2. Instruir a memória principal a realizar uma leitura e esperar que a memória complete seu acesso. 3. Escrever na entrada da cache, colocando os dados da memória na parte dos dados da entrada, escrevendo os bits mais significativos do endereço (vindo da ALU) no campo tag e ligando o bit de validade. 4. Reiniciar a execução da instrução na primeira etapa, o que buscará novamente a instrução, desta vez encontrando-a na cache. O controle da cache sobre um acesso de dados é basicamente idêntico: em uma falha, simplesmente suspendemos o processador até que a memória responda com os dados.
Tratando escritas As escritas funcionam de maneira um pouco diferente. Suponha que, em uma instrução store, escrevemos os dados apenas na cache de dados (sem alterar a memória principal); então, após a escrita na cache, a memória teria um valor diferente do valor na cache. Nesse caso, dizemos que a cache e a memória estão inconsistentes. A maneira mais simples de manter consistentes a memória principal e a cache é sempre escrever os dados na memória e na cache. Esse esquema é chamado write-through.
write-through Um esquema em que as escritas sempre atualizam a cache e o próximo nível inferior da hierarquia de memória, garantindo que os dados sejam sempre consistentes entre os dois. O outro aspecto importante das escritas é o que ocorre em uma falha de dados. Primeiro, buscamos as palavras do bloco da memória. Após o bloco ser buscado e colocado na cache, podemos substituir (sobrescrever) a palavra que causou a falha no bloco de cache. Também escrevemos a palavra na memória principal usando o endereço completo. Embora esse projeto trate das escritas de maneira muito simples, ele não oferece um desempenho muito bom. Com um esquema de write-through, toda escrita faz com que os dados sejam escritos na memória principal. Essas escritas levarão muito tempo, talvez mais de 100 ciclos de clock de processador, e
tornariam o processador consideravelmente mais lento. Por exemplo, suponha que 10% das instruções sejam stores. Se o CPI sem falhas de cache fosse 1,0, gastar 100 ciclos extras em cada escrita levaria a um CPI de 1,0 + 100 × 10% = 11, reduzindo o desempenho por um fator maior que 10. Uma solução para esse problema é usar um buffer de escrita (ou write buffer), que armazena os dados enquanto estão esperando para serem escritos na memória. Após escrever os dados na cache e no buffer de dados, o processador pode continuar a execução. Quando uma escrita na memória principal é concluída, a entrada no buffer de escrita é liberada. Se o buffer de escrita estiver cheio quando o processador atingir uma escrita, o processador precisará sofrer stall até que haja uma posição vazia no buffer de escrita. Naturalmente, se a velocidade em que a memória pode completar escritas for menor do que a velocidade em que o processador está gerando escritas, nenhuma quantidade de buffer pode ajudar, pois as escritas estão sendo geradas mais rápido do que o sistema de memória pode aceitá-las.
buffer de escrita Uma fila que contém os dados enquanto estão esperando para serem escritos na memória. A velocidade em que as escritas são geradas também pode ser menor do que a velocidade com que a memória pode aceitá-las, e stalls ainda podem ocorrer. Isso pode acontecer quando as escritas ocorrem em bursts (ou rajadas). Para reduzir a ocorrência desses stalls, os processadores normalmente aumentam a profundidade do buffer de escrita para além de uma única entrada. A alternativa para um esquema write-through é um esquema chamado writeback, no qual, quando ocorre uma escrita, o novo valor é escrito apenas no bloco da cache. O bloco modificado é escrito no nível inferior da hierarquia quando ele é substituído. Os esquemas write-back podem melhorar o desempenho, especialmente quando os processadores podem gerar escritas tão rápido ou mais rápido do que as escritas podem ser tratadas pela memória principal; entretanto, um esquema write-back é mais complexo de implementar do que um esquema write-through.
write-back Um esquema que trata das escritas atualizando valores apenas no bloco da
cache e, depois, escrevendo o bloco modificado no nível inferior da hierarquia quando o bloco é substituído. No restante desta seção, descreveremos as caches de processadores reais e examinaremos como elas tratam leituras e escritas. Na Seção 5.8, descreveremos o tratamento de escritas em mais detalhes.
Detalhamento As escritas introduzem várias complicações nas caches que não estão presentes para leituras. Discutiremos aqui duas delas: a política nas falhas de escrita e a implementação eficiente das escritas em caches write-back. Considere uma falha em uma cache write-through. A estratégia mais comum é alocar um bloco na cache, chamado alocar na escrita. O bloco é apanhado da memória e depois a parte apropriada do bloco é sobrescrita. Uma estratégia alternativa é atualizar a parte do bloco na memória, mas não colocála em cache, o que se chama não alocar na escrita. A motivação é que, às vezes, os programas escrevem blocos de dados, como quando o sistema operacional zera uma página de memória. Nesses casos, a busca associada com a falha de escrita inicial pode ser desnecessária. Alguns computadores permitem que a política de alocação de escrita seja alterada com base em cada página. Implementar stores de modo realmente eficaz em uma cache que usa uma estratégia write-back é mais complexo do que em uma cache write-through. Uma cache write-through pode escrever os dados na cache e ler a tag; se a tag for diferente, então haverá uma falha. Como a cache é write-through, a substituição do bloco na cache não é catastrófica, pois a memória tem o valor correto. Em uma cache write-back, precisamos escrever o bloco novamente na memória se os dados na cache estiverem modificados e tivermos uma falha de cache. Se simplesmente substituíssemos o bloco em uma instrução store antes de sabermos se o store teve acerto na cache (como poderíamos fazer para uma cache write-through), destruiríamos o conteúdo do bloco, que não é copiado, no próximo nível da hierarquia da memória. Em uma cache write-back, como não podemos substituir o bloco, os stores ou exigem dois ciclos (um ciclo para verificar um acerto seguido de um ciclo para efetivamente realizar a escrita) ou exigem um buffer de escrita para conter esses dados — na prática, permitindo que o store leve apenas um ciclo
por meio de um pipeline de memória. Quando um buffer de store é usado, o processador realiza a consulta de cache e coloca os dados no buffer de store durante o ciclo de acesso de cache normal. Considerando um acerto de cache, os novos dados são escritos do buffer de store para a cache no próximo ciclo de acesso de cache não usado. Por comparação, em uma cache write-through, as escritas sempre podem ser feitas em um ciclo. Lemos a tag e escrevemos a parte dos dados do bloco selecionado. Se a tag corresponder ao endereço do bloco escrito, o processador pode continuar normalmente, já que o bloco correto foi atualizado. Se a tag não corresponder, o processador gera uma falha de escrita para buscar o resto do bloco correspondente a esse endereço. Muitas caches write-back também incluem buffers de escrita usados para reduzir a penalidade de falha quando uma falha substitui um bloco modificado. Em casos como esse, o bloco modificado é movido para um buffer write-back associado com a cache enquanto o bloco requisitado é lido da memória. Depois, o buffer write-back é escrito novamente na memória. Considerando que outra falha não ocorra imediatamente, essa técnica reduz à metade a penalidade de falha quando um bloco modificado precisa ser substituído.
Uma cache de exemplo: o processador Intrinsity FastMATH O Intrinsity FastMATH é um microprocessador embutido que usa a arquitetura MIPS e uma implementação de cache simples. Próximo ao final do capítulo, examinaremos o projeto de cache mais complexo dos microprocessadores ARM e Intel, mas começaremos com este exemplo simples, mas real, por questões didáticas. A Figura 5.12 mostra a organização da cache de dados do Intrinsity FastMATH.
FIGURA 5.12 Cada cache de 16 KiB no Intrinsity FastMATH contém 256 blocos com 16 palavras por bloco. O campo tag possui 18 bits de largura, e o campo índice possui 8 bits de largura, enquanto um campo de 4 bits (bits 5 a 2) é usado para indexar o bloco e selecionar a palavra do bloco usando um multiplexador de 16 para 1. Na prática, para eliminar o multiplexador, as caches usam uma RAM grande separada para os dados e uma RAM menor para as tags, com o offset de bloco fornecendo os bits de endereço extras para a RAM grande de dados. Nesse caso, a RAM grande possui 32 bits de largura e precisa ter 16 vezes o número de palavras como blocos na cache.
Este processador possui um pipeline de 12 estágios. Quando está operando na velocidade de pico, o processador pode requisitar uma palavra de instrução e uma palavra de dados em cada clock. Para satisfazer às demandas do pipeline sem stalls, são usadas caches de instruções e de dados separadas. Cada cache possui 16 KiB, ou 4096 palavras, com blocos de 16 palavras. As requisições de leitura para a cache são simples. Como existem caches de dados e de instruções separadas, sinais de controle separados serão necessários para ler e escrever em cada cache. (Lembre-se de que precisamos atualizar a cache de instruções quando ocorre uma falha.) Portanto, as etapas para uma requisição de leitura para qualquer uma das caches são as seguintes:
1. Enviar o endereço à cache apropriada. O endereço vem do PC (para uma instrução) ou da ALU (para dados). 2. Se a cache sinalizar acerto, a palavra requisitada estará disponível nas linhas de dados. Como existem 16 palavras no bloco desejado, precisamos selecionar a palavra correta. Um campo índice de bloco é usado para controlar o multiplexador (mostrado na parte inferior da figura), que seleciona a palavra requisitada das 16 palavras do bloco indexado. 3. Se a cache sinalizar falha, enviaremos o endereço para a memória principal. Quando a memória retorna com os dados, nós os escrevemos na cache e, então, os lemos para atender à requisição. Para escritas, o Intrinsity FastMATH oferece write-through e write-back, deixando a cargo do sistema operacional decidir qual estratégia usar para cada aplicação. Ele possui um buffer de escrita de uma entrada. Que taxas de falhas de cache são atingidas com uma estrutura de cache como a usada pelo Intrinsity FastMATH? A Figura 5.13 mostra as taxas de falhas para as caches de instruções e de dados. A taxa de falhas combinada é a taxa de falhas efetiva por referência para cada programa após considerar a frequência diferente dos acessos a instruções e a dados.
FIGURA 5.13 Taxas de falhas de instruções e dados aproximadas para o processador Intrinsity FastMATH para benchmarks SPEC CPU2000. A taxa de falhas combinada é a taxa de falhas efetiva para a combinação da cache de instruções de 16 KiB. e da cache de dados de 16 KiB.. Ela é obtida ponderando as taxas de falhas individuais de instruções e de dados pela frequência das referências a instruções e dados.
Embora a taxa de falhas seja uma característica importante dos projetos de cache, a medida decisiva será o efeito do sistema de memória sobre o tempo de execução do programa; em breve veremos como a taxa de falhas e o tempo de execução estão relacionados.
Detalhamento
Uma cache combinada com um tamanho total igual à soma das duas caches divididas normalmente terá uma taxa de acertos melhor. Essa taxa mais alta ocorre porque a cache combinada não divide rigidamente o número de entradas que podem ser usadas por instruções daquelas que podem ser usadas por dados. Entretanto, muitos processadores usam uma instrução split e uma cache de dados para aumentar a largura de banda da cache. (Também pode haver menos falhas de conflito; veja Seção 5.8.) Aqui estão taxas de falhas para caches do tamanho dos encontrados no processador Intrinsity FastMATH, e para uma cache combinada cujo tamanho é igual ao total das duas caches: ▪ Tamanho total da cache: 32KB. ▪ Taxa de falhas efetiva da cache dividida: 3,24%. ▪ Taxa de falhas da cache combinada: 3,18%. A taxa de falhas da cache dividida é apenas ligeiramente pior. A vantagem de dobrar a largura de banda da cache, suportando acessos a instruções e a dados simultaneamente, logo suplanta a desvantagem de uma taxa de falhas um pouco maior. Essa constatação é outro lembrete de que não podemos usar a taxa de falhas como a única medida de desempenho de cache, como mostra a Seção 5.4.
cache dividida Um esquema em que um nível da hierarquia de memória é composto de duas caches independentes que operam em paralelo uma com a outra, com uma tratando instruções e a outra manipulando dados.
Resumo Começamos a seção anterior examinando a mais simples das caches: uma cache diretamente mapeada com um bloco de uma palavra. Nesse tipo de cache, tanto os acertos quanto as falhas são simples, já que uma palavra pode estar localizada exatamente em um lugar e existe uma tag separada para cada palavra. A fim de manter a cache e a memória consistentes, um esquema de write-through pode ser usado, de modo que toda escrita na cache também faz com que a memória seja atualizada. A alternativa ao write-through é um esquema write-back, que copia um bloco de volta para a memória quando ele é substituído; discutiremos esse esquema mais detalhadamente em seções futuras.
Para tirar vantagem da localidade espacial, uma cache precisa ter um tamanho de bloco maior do que uma palavra. O uso de um bloco maior diminui a taxa de falhas e melhora a eficiência da cache reduzindo a quantidade de armazenamento de tag em relação à quantidade de armazenamento de dados na cache. Embora um tamanho de bloco maior diminua a taxa de falhas, ele também pode aumentar a penalidade de falha. Se a penalidade de falha aumentasse linearmente com o tamanho de bloco, blocos maiores poderiam facilmente levar a um desempenho menor. Para evitar a perda de desempenho, a largura de banda da memória principal é aumentada de modo a transferir blocos de cache de maneira mais eficiente. Os dois métodos comuns para aumentar a largura de banda externa à DRAM são tornar a memória mais larga e a intercalação. Os projetistas de DRAM têm constantemente melhorado a interface entre o processador e a memória, a fim de aumentar a largura de banda das transferências no modo rajado e reduzir o custo dos tamanhos de bloco de cache maiores.
Verifique você mesmo A velocidade do sistema de memória afeta a decisão do projetista sobre o tamanho do bloco de cache. Quais dos seguintes princípios de projeto de cache normalmente são válidos? 1. Quanto mais curta for a latência da memória, menor será o bloco de cache. 2. Quanto mais curta for a latência da memória, maior será o bloco de cache. 3. Quanto maior for a largura de banda da memória, menor será o bloco de cache. 4. Quanto maior for a largura de banda da memória, maior será o bloco de cache.
5.4. Medindo e melhorando o desempenho da cache Nesta seção, começamos examinando como medir e analisar o desempenho da cache; depois, exploramos duas técnicas diferentes para melhorar o desempenho da cache. Uma delas focaliza o decréscimo da taxa de falhas reduzindo a probabilidade de dois blocos de memória diferentes disputarem o mesmo local da cache. A segunda técnica reduz a penalidade de falha acrescentando um nível adicional na hierarquia. Essa técnica, chamada caching multinível, apareceu
inicialmente nos computadores de topo de linha sendo vendidos por mais de US$100.000 em 1990; desde então, ela se tornou comum nos computadores desktop vendidos por algumas centenas de dólares! O tempo de CPU pode ser dividido nos ciclos de clock que a CPU gasta executando o programa e os ciclos de clock que gasta esperando o sistema de memória. Normalmente, consideramos que os custos do acesso à cache que são acertos fazem parte dos ciclos de execução normais da CPU. Portanto,
Os ciclos de clock de stall de memória vêm principalmente das falhas de cache, e é isso que iremos considerar aqui. Também limitamos a discussão a um modelo simplificado do sistema de memória. Nos processadores reais, os stalls gerados por leituras e escritas podem ser muito complexos, e a previsão correta do desempenho normalmente exige simulações extremamente detalhadas do processador e do sistema de memória. Os ciclos de clock de stall de memória podem ser definidos como a soma dos ciclos de stall vindo das leituras, mais os provenientes das escritas:
Os ciclos de stall de leitura podem ser definidos em função do número de acessos de leitura por programa, a penalidade de falha nos ciclos de clock para uma leitura e a taxa de falhas de leitura:
As escritas são mais complicadas. Para um esquema write-through, temos duas origens de stalls: as falhas de escrita, que normalmente exigem que busquemos o bloco antes de continuar a escrita (veja a seção “Detalhamento” na
Seção “Tratando escritas”, anteriormente neste capítulo, para obter mais informações sobre como lidar com escritas), e os stalls do buffer de escrita, que ocorrem quando o buffer de escrita está cheio ao ocorrer uma escrita. Assim, os ciclos de stall para escritas são iguais à soma desses dois fatores:
Como os stalls do buffer de escrita dependem da proximidade das escritas e não apenas da frequência, não é possível fornecer uma equação simples para calcular esses stalls. Felizmente, nos sistemas com uma profundidade de buffer de escrita razoável (por exemplo, quatro ou mais palavras) e uma memória capaz de aceitar escritas em uma velocidade que excede, significativamente, a frequência de escrita média em programas (por exemplo, por um fator de duas vezes), os stalls do buffer de escrita serão pequenos e podemos ignorá-los. Se um sistema não atendesse a esse critério, ele não seria bem projetado; ao contrário, o projetista deveria ter usado um buffer de escrita mais profundo ou uma organização write-back. Os esquemas write-back também possuem stalls potenciais extras surgindo da necessidade de escrever um bloco de cache, novamente na memória, quando o bloco é substituído. Discutiremos mais o assunto na Seção 5.8. Na maioria das organizações de cache write-through, as penalidades de falha de leitura e escrita são iguais (o tempo para buscar o bloco da memória). Se considerarmos que os stalls do buffer de escrita são insignificantes, podemos combinar as leituras e escritas usando uma única taxa de falhas e a penalidade de falha:
Também podemos fatorar isso como
Vamos considerar um exemplo simples para ajudar a entender o impacto no desempenho da cache sobre o desempenho do processador.
Calculando o desempenho da cache Exemplo Suponha que uma taxa de falhas de cache de instruções para um programa seja de 2% e que uma taxa de falhas de cache de dados seja de 4%. Se um processador possui um CPI de 2 sem qualquer stall de memória e a penalidade de falha é de 100 ciclos para todas as falhas, determine o quão mais rápido um processador executaria com uma cache perfeita que nunca falhasse. Suponha que a frequência de todos os loads e stores seja 36%.
Resposta O número de ciclos de falha da memória para instruções em termos da contagem de instruções (I) é
A frequência de todos os loads e stores é de 36%. Logo, podemos encontrar o número de ciclos de falha da memória para referências de dados:
O número total de ciclos de stall da memória é 2,00 I + 1,44 I = 3,44 I. Isso é mais do que três ciclos de stall da memória por instrução. Portanto, o CPI com stalls da memória é 2 + 3,44 = 5,44. Como não há mudança alguma na contagem de instruções ou na velocidade de clock, a taxa dos tempos de execução da CPU é
O desempenho com a cache perfeita é melhor por um fator de
. O que acontece se o processador se tornar mais rápido, mas o sistema de memória não? A quantidade de tempo gasto nos stalls da memória tomará uma fração cada vez maior do tempo de execução; a Lei de Amdahl, que examinamos no Capítulo 1, nos lembra desse fato. Alguns exemplos simples mostram como esse problema pode ser sério. Suponha que aceleremos o computador do exemplo anterior reduzindo seu CPI de 2 para 1 sem mudar a velocidade de clock, o que pode ser feito com um pipeline melhorado. O sistema com falhas de cache, então, teria um CPI de 1 + 3,44 = 4,44, e o sistema com a cache perfeita seria
A quantidade de tempo de execução gasto em stalls da memória teria subido de
para
Da mesma forma, aumentar a velocidade de clock sem mudar o sistema de memória também aumenta a perda de desempenho devido às falhas de cache. Os exemplos e equações anteriores consideram que o tempo de acerto não é um fator na determinação do desempenho da cache. Claramente, se o tempo de acerto aumentar, o tempo total para acessar uma palavra do sistema de memória crescerá, possivelmente causando um aumento no tempo de ciclo do processador. Embora vejamos em breve outros exemplos do que pode aumentar o tempo de acerto, um exemplo é aumentar o tamanho da cache. Uma cache maior pode ter um tempo de acesso maior, exatamente como se sua mesa na biblioteca fosse muito grande (digamos, 3 m2): você levaria mais tempo para localizar um livro sobre a mesa. Um aumento no tempo de acerto provavelmente acrescenta outro estágio ao pipeline, já que podem ser necessários vários ciclos para um acerto de cache. Embora seja mais complexo calcular o impacto de desempenho de um pipeline mais profundo, em algum ponto, o aumento no tempo de acerto para uma cache maior pode dominar a melhoria na taxa de acertos, levando a uma redução no desempenho do processador. A fim de capturar o fato de que o tempo de acesso a dados para acertos e falhas afeta o desempenho, os projetistas, às vezes, usam tempo médio de acesso à memória (TMAM) como um modo de examinar os projetos de cache alternativos. O tempo médio de acesso à memória é o tempo médio para acessar a memória, considerando acertos, falhas e a frequência dos diferentes acessos; ele é igual ao seguinte:
Calculando o tempo médio de acesso à memória Exemplo Ache o TMAM para um processador com tempo de clock de 1 ns, uma penalidade de falha de 20 ciclos de clock, uma taxa de falhas de 0,05 falhas por instrução e um tempo de acesso a cache (incluindo detecção de acerto) de 1 ciclo de clock. Suponha que as penalidades de perda de leitura e escrita
sejam iguais e ignore outros stalls de escrita.
Resposta O tempo médio de acesso à memória por instrução é
ou 2 ns. A próxima subseção discute organizações de cache alternativas que diminuem a taxa de falhas mas podem, algumas vezes, aumentar o tempo de acerto; outros exemplos aparecem em Falácias e armadilhas (Seção 5.13).
Reduzindo as falhas de cache com um posicionamento de blocos mais flexível Até agora, quando colocamos um bloco na cache, usamos um esquema de posicionamento simples: um bloco só pode entrar exatamente em um local na cache. Como já dissemos, esse esquema é chamado de mapeamento direto porque qualquer endereço de bloco na memória é diretamente mapeado para um único local, no nível superior da hierarquia. Existe, na verdade, toda uma faixa de esquemas para posicionamento de blocos. Em um extremo está o mapeamento direto, em que um bloco só pode ser posicionado exatamente em um local. No outro extremo está um esquema em que um bloco pode ser posicionado em qualquer local na cache. Esse esquema é chamado de totalmente associativo porque um bloco na memória pode ser associado com qualquer entrada da cache. Para encontrar um determinado bloco em uma cache totalmente associativa, todas as entradas da cache precisam ser pesquisadas, pois um bloco pode estar posicionado em qualquer uma delas. Para tornar a pesquisa possível, ela é feita em paralelo com um comparador associado a cada entrada da cache. Esses comparadores aumentam muito o custo do hardware, na prática, tornando o posicionamento totalmente associativo, viável apenas para caches com pequenos números de blocos.
cache totalmente associativa Uma estrutura de cache em que um bloco pode ser posicionado em qualquer local da cache. A faixa intermediária de projetos entre a cache diretamente mapeada e a cache totalmente associativa é chamada de associativa por conjunto. Em uma cache associativa por conjunto, existe um número fixo de locais (pelo menos dois) onde cada bloco pode ser colocado; uma cache associativa por conjunto com n locais para um bloco é chamado de cache associativa por conjunto de n vias. Uma cache associativa por conjunto de n vias consiste em diversos conjuntos, cada um consistindo em n blocos. Cada bloco na memória é mapeado para um conjunto único na cache, determinado pelo campo índice, e um bloco pode ser colocado em qualquer elemento desse conjunto. Portanto, um posicionamento associativo por conjunto combina o posicionamento diretamente mapeado e o posicionamento totalmente associativo: um bloco é diretamente mapeado para um conjunto e, então, uma correspondência é pesquisada em todos os blocos no conjunto. Por exemplo, a Figura 5.14 mostra onde o bloco 12 pode ser posicionado em uma cache com oito blocos no total, conforme as três políticas de posicionamento de bloco.
FIGURA 5.14 O local de um bloco de memória cujo endereço é 12 em uma cache com 8 blocos varia para posicionamento diretamente mapeado, associativo por conjunto e totalmente associativo. No posicionamento diretamente mapeado, há apenas um bloco de cache em que o bloco de memória 12 pode ser encontrado, e
esse bloco é dado por (12 módulo 8) = 4. Em uma cache associativa por conjunto de duas vias, haveria quatro conjuntos e o bloco de memória 12 precisa estar no conjunto (12 mod 4) = 0; o bloco de memória pode estar em qualquer elemento do conjunto. Em um posicionamento totalmente associativo, o bloco de memória para o endereço de bloco 12 pode aparecer em qualquer um dos oito blocos de cache.
cache associativa por conjunto Uma cache que possui um número fixo de locais (no mínimo dois) onde cada bloco pode ser colocado. Lembre-se de que, em uma cache diretamente mapeada, a posição de um bloco de memória é determinada por
Em uma cache associativa por conjunto, o conjunto contendo um bloco de memória é determinado por
Como o bloco pode ser colocado em qualquer elemento do conjunto, todas as tags de todos os elementos do conjunto precisam ser pesquisadas. Em uma cache totalmente associativa, o bloco pode entrar em qualquer lugar e todas as tags de todos os blocos na cache precisam ser pesquisadas. Podemos pensar em cada estratégia de posicionamento de bloco como uma variação da associatividade por conjunto. A Figura 5.15 mostra as possíveis estruturas de associatividade para uma cache de oito blocos. Uma cache diretamente mapeada é simplesmente uma cache associativa por conjunto de uma via: cada entrada de cache contém um bloco, e cada conjunto possui um elemento. Uma cache totalmente associativa com m entradas é simplesmente uma cache associativa por conjunto de m vias; ele tem um conjunto com m blocos, e uma entrada pode residir em qualquer bloco dentro desse conjunto.
FIGURA 5.15 Uma cache de oito blocos configurada como diretamente mapeada, associativa por conjunto de duas vias, associativa por conjunto de quatro vias e totalmente associativa. O tamanho total da cache em blocos é igual ao número de conjuntos multiplicado pela associatividade. Portanto, para uma cache de tamanho fixo, aumentar a associatividade diminui o número de conjuntos enquanto aumenta o número de elementos por conjunto. Com oito blocos, uma cache associativa por conjunto de oito vias é igual a uma cache totalmente associativa.
A vantagem de aumentar o grau da associatividade é que ela normalmente diminui a taxa de falhas, como mostra o próximo exemplo. A principal desvantagem, que veremos com mais detalhes em breve, é um potencial aumento no tempo de acerto.
Falhas e associatividade nas caches Exemplo
Considere três caches pequenas, cada uma consistindo em quatro blocos de uma palavra cada. Uma cache é totalmente associativa, uma segunda cache é associativa por conjunto de duas vias, e a terceira cache é diretamente mapeada. Encontre o número de falhas para cada organização de cache, dada a seguinte sequência de endereços de bloco: 0, 8, 0, 6 e 8.
Resposta O caso diretamente mapeado é mais fácil. Primeiro, vamos determinar para qual bloco de cache cada endereço de bloco é mapeado: Endereço do bloco
Bloco de cache
0
(0 módulo 4) = 0
6
(6 módulo 4) = 2
8
(8 módulo 4) = 0
Agora podemos preencher o conteúdo da cache após cada referência, usando uma entrada em branco para indicar que o bloco é inválido, texto em negrito para mostrar uma nova entrada incluída na cache para a referência associada e um texto normal para mostrar uma entrada existente na cache: Endereço do bloco de memória associado Acerto ou falha Conteúdo dos blocos de cache após referência 0
1
2
0
falha
Memória[0]
8
falha
Memória[8]
0
falha
Memória[0]
6
falha
Memória[0]
Memória[6]
8
falha
Memória[8]
Memória[6]
3
A cache diretamente mapeada gera cinco falhas para os cinco acessos. A cache associativa por conjunto possui dois conjuntos (com índices 0 e 1) com dois elementos por conjunto. Primeiro, vamos determinar para qual conjunto cada endereço de bloco é mapeado: Endereço do bloco
Bloco de cache
0
(0 módulo 2) = 0
6
(6 módulo 2) = 0
8
(8 módulo 2) = 0
Já que temos uma escolha de qual entrada em um conjunto substituir em uma falha, precisamos de uma regra de substituição. As caches associativas por conjunto normalmente substituem o bloco usado menos recentemente dentro de um conjunto; ou seja, o bloco usado há mais tempo é substituído. (Discutiremos outras regras de substituição mais detalhadamente em breve.) Usando esta regra de substituição, o conteúdo da cache associativa por conjunto após cada referência se parece com o seguinte: Endereço do bloco de memória associado
Acerto ou falha
Conteúdo dos blocos de cache após referência Conjunto 0
Conjunto 0
0
falha
Memória[0 ]
8
falha
Memória[0]
Memória[8 ]
0
acerto
Memória[0]
Memória[8]
6
falha
Memória[0]
Memória[6 ]
8
falha
Memória[8 ]
Memória[6]
Conjunto 1
Conjunto 1
Observe que, quando o bloco 6 é referenciado, ele substitui o bloco 8, já que o bloco 8 foi referenciado menos recentemente do que o bloco 0. A cache associativa por conjunto de duas vias possui quatro falhas, uma a menos do que a cache diretamente mapeada. A cache totalmente associativa possui quatro blocos de cache (em um único conjunto); qualquer bloco de memória pode ser armazenado em qualquer bloco de cache. A cache totalmente associativa possui o melhor desempenho, com apenas três falhas: Endereço do bloco de memória associado Acerto ou falha
Conteúdo dos blocos de cache após referência Bloco 0
Bloco 1
Bloco 2
0
falha
Memória[0]
8
falha
Memória[0]
Memória[8]
0
acerto
Memória[0]
Memória[8]
6
falha
Memória[0]
Memória[8]
Memória[6]
8
acerto
Memória[0]
Memória[8]
Memória[6]
Bloco 3
Para essa série de referências, três falhas é o melhor que podemos fazer porque três endereços de bloco únicos são acessados. Repare que se
tivéssemos oito blocos na cache, não haveria qualquer substituição na cache associativa por conjunto de duas vias (confira isso você mesmo), e ele teria o mesmo número de falhas da cache totalmente associativa. Da mesma forma, se tivéssemos 16 blocos, todas as três caches teriam o mesmo número de falhas. Até mesmo esse exemplo trivial mostra que o tamanho da cache e a associatividade não são independentes para a determinação do desempenho da cache. Quanta redução na taxa de falhas é obtida pela associatividade? A Figura 5.16 mostra a melhoria para uma cache de dados de 64 KiB com um bloco de 16 palavras e mostra a associatividade mudando do mapeamento direto para oito vias. Passar da associatividade de uma via para duas vias diminui a taxa de falhas em, aproximadamente 15%, mas há pouca melhora adicional em passar para uma associatividade mais alta.
FIGURA 5.16 As taxas de falhas da cache de dados para uma organização como o processador Intrinsity FastMATH para benchmarks SPEC CPU2000 com associatividade variando de uma via a oito vias. Esses resultados para dez programas SPEC CPU2000 são de Hennessy et al. (2003).
Localizando um bloco na cache Agora, vamos considerar a tarefa de encontrar um bloco em uma cache que é associativa por conjunto. Assim como em uma cache diretamente mapeada, cada bloco em uma cache associativa por conjunto inclui uma tag de endereço que fornece o endereço do bloco. A tag de cada bloco de cache dentro do conjunto apropriado é verificada para ver se corresponde ao endereço de bloco vindo do processador. A Figura 5.17 mostra como o endereço é decomposto. O valor de índice é usado para selecionar o conjunto contendo o endereço de interesse, e as
tags de todos os blocos no conjunto precisam ser pesquisadas. Como a velocidade é a essência da pesquisa, todas as tags no conjunto selecionado são pesquisadas em paralelo. Assim como em uma cache totalmente associativa, uma pesquisa sequencial tornaria o tempo de acerto de uma cache associativa por conjunto muito lento.
FIGURA 5.17 As três partes de um endereço em uma cache associativa por conjunto ou diretamente mapeada. O índice é usado para selecionar o conjunto e, depois, a tag é usada para escolher o bloco por comparação com os blocos no conjunto selecionado. O offset do bloco é o endereço dos dados desejados dentro do bloco.
Se o tamanho de cache total for mantido igual, aumentar a associatividade aumenta o número de blocos por conjunto, que é o número de comparações simultâneas necessárias para realizar a pesquisa em paralelo: cada aumento por um fator de dois na associatividade dobra o número de blocos por conjunto e divide por dois o número de conjuntos. Assim, cada aumento pelo dobro na associatividade diminui o tamanho do índice em 1 bit e aumenta o tamanho da tag em 1 bit. Em uma cache totalmente associativa, existe apenas um conjunto, e todos os blocos precisam ser verificados em paralelo. Portanto, não há qualquer índice, e o endereço inteiro, excluindo o offset do bloco, é comparado com a tag de cada bloco. Em outras palavras, a cache inteira é pesquisada sem qualquer indexação. Em uma cache diretamente mapeada, apenas um único comparador é necessário, pois a entrada pode estar apenas em um bloco, e acessamos a cache por meio da indexação. A Figura 5.18 mostra que em uma cache associativa por conjunto de quatro vias, quatro comparadores são necessários, juntamente com um multiplexador de 4 para 1, a fim de escolher entre os quatro números possíveis do conjunto selecionado. O acesso de cache consiste em indexar o conjunto apropriado e, depois, pesquisar as tags do conjunto. Os custos de uma cache associativa são os comparadores extras e qualquer atraso gerado pela necessidade de comparar e selecionar entre os elementos do conjunto.
FIGURA 5.18 A implementação de uma cache associativa por conjunto de quatro vias exige quatro comparadores e um multiplexador (mux) de 4 para 1. Os comparadores determinam qual elemento do conjunto selecionado (se houver) corresponde à tag. A saída dos comparadores é usada para selecionar os dados de um dos quatro blocos do conjunto indexado, usando um multiplexador com um sinal de seleção decodificado. Em algumas implementações, a saída permite que sinais nas partes de dados das RAMs de cache possam ser usados para selecionar a entrada no conjunto que controla a saída. A saída permite que o sinal venha dos comparadores, fazendo com que o elemento correspondente controle as saídas de dados. Essa organização elimina a necessidade do multiplexador.
A escolha entre mapeamento direto, associativo por conjunto ou totalmente associativo em qualquer hierarquia de memória dependerá do custo de uma falha em comparação com o custo da implementação da associatividade, ambos em
tempo e em hardware extra.
Detalhamento Uma Content Addressable Memory (CAM) é um circuito que combina comparação e armazenamento em um único dispositivo. Em vez de fornecer um endereço e ler uma palavra como uma RAM, você fornece os dados e a CAM verifica se tem uma cópia e retorna o índice da linha correspondente. As CAMs significam que os projetistas de cache podem proporcionar a implementação de uma associatividade por conjunto muito mais alta do que se tivessem de construir o hardware a partir de SRAMs e comparadores. Em 2013, o maior tamanho e potência da CAM geralmente levam a uma associatividade por conjunto em duas vias e quatro vias sendo construída a partir de SRAMs padrão e comparadores, com oito vias em diante sendo construídas usando CAMs.
Escolhendo que bloco substituir Quando ocorre uma falha em uma cache diretamente mapeada, o bloco requisitado só pode entrar em exatamente uma posição, e o bloco ocupando essa posição precisa ser substituído. Em uma cache associativa, temos uma escolha de onde colocar o bloco requisitado e, portanto, uma escolha de qual bloco substituir. Em uma cache totalmente associativa, todos os blocos são candidatos à substituição. Em uma cache associativa por conjunto, precisamos escolher entre os blocos do conjunto selecionado. O esquema mais comum é o LRU (Least Recently Used — usado menos recentemente), que usamos no exemplo anterior. Em um esquema LRU, o bloco substituído é aquele que não foi usado há mais tempo. O exemplo associativo por conjunto demonstrado anteriormente neste capítulo utiliza LRU, que é o motivo pelo qual substituímos Memória(0) ao invés de Memória(6).
LRU (Least Recently Used — usado menos recentemente) Um esquema de substituição em que o bloco substituído é aquele que não foi usado há mais tempo.
A substituição LRU é implementada monitorando quando cada elemento em um conjunto foi usado em relação aos outros elementos no conjunto. Para uma cache associativa por conjunto de duas vias, o controle de quando os dois elementos foram usados pode ser implementado mantendo um único bit em cada conjunto e definindo o bit para indicar um elemento sempre que este é referenciado. Conforme a associatividade aumenta, a implementação do LRU se torna mais difícil; na Seção 5.8, veremos um esquema alternativo para substituição.
Tamanho das tags versus associatividade do conjunto Exemplo O acréscimo da associatividade requer mais comparadores e mais bits de tag por bloco de cache. Considerando uma cache de 4096 blocos, um tamanho de bloco de quatro palavras e um endereço de 32 bits, encontre o número total de conjuntos e o número total de bits de tag para caches que são diretamente mapeadas, associativas por conjunto de duas e quatro vias, totalmente associativas.
Resposta Como existem 16 (=24) bytes por bloco, um endereço de 32 bits produz 32 – 4 = 28 bits para serem usados para índice e tag. A cache diretamente mapeada possui um mesmo número de conjuntos e blocos e, portanto, 12 bits de índice, já que log2(4096) = 12; logo, o número total é (28 – 12) × 4096 = 16 × 4096 = 66 K bits de tag. Cada grau de associatividade diminui o número de conjuntos por um fator de dois e, portanto, diminui o número de bits usados para indexar a cache por um e aumenta o número de bits na tag por um. Consequentemente, para uma cache associativa por conjunto de duas vias, existem 2048 conjuntos, e o número total é (28 – 11) × 2 × 2048 = 34 × 2048 = 70 K bits de tag. Para uma cache associativa por conjunto de quatro vias, o número total de conjuntos é 1024, e o número total é (28 – 10) × 4 × 1024 = 72 × 1024 = 74 K bits de tag. Para uma cache totalmente associativa, há apenas um conjunto com 4096 blocos, e a tag possui 28 bits, produzindo um total de 28 × 4096 × 1 = 115 K bits de tag.
Reduzindo a penalidade de falha usando caches multiníveis Todos os computadores modernos fazem uso de caches. Para diminuir a diferença entre as rápidas velocidades de clock dos processadores modernos e o tempo relativamente longo necessário para acessar as DRAMs, muitos microprocessadores suportam um nível adicional de cache. Essa cache de segundo nível normalmente está no mesmo chip e é acessada sempre que ocorre uma falha na cache primária. Se a cache de segundo nível contiver os dados desejados, a penalidade de falha para a cache de primeiro nível será o tempo de acesso à cache de segundo nível, que será muito menor do que o tempo de acesso à memória principal. Se nem a cache primária nem a secundária contiverem os dados, um acesso à memória principal será necessário, e uma penalidade de falha maior será observada. Em que grau é significante a melhora de desempenho pelo uso de uma cache secundária? O próximo exemplo nos mostra.
Desempenho das caches multinível Exemplo Suponha que tenhamos um processador com um CPI básico de 1,0, considerando que todas as referências acertem na cache primária e uma velocidade de clock de 4GHz. Considere um tempo de acesso à memória principal de 100 ns, incluindo todo o tratamento de falhas. Suponha que a taxa de falhas por instrução na cache primária seja de 2%. O quão mais rápido será o processador se acrescentarmos uma cache secundária que tenha um tempo de acesso de 5 ns para um acerto ou uma falha e que seja grande o suficiente de modo a reduzir a taxa de falhas para a memória principal para 0,5%?
Resposta A penalidade de falha para a memória principal é
O CPI efetivo com um nível de cache é dado por
Para o processador com um nível de caching,
Com dois níveis de cache, uma falha na cache primária (ou de primeiro nível) pode ser preenchida pela cache secundária ou pela memória principal. A penalidade da falha para um acesso à cache de segundo nível é
Se a falha for preenchida na cache secundária, essa será toda a penalidade de falha. Se a falha precisar ir à memória principal, então, a penalidade de falha total será a soma do tempo de acesso à cache secundária e do tempo de acesso à memória principal. Logo, para uma cache de dois níveis, o CPI total é a soma dos ciclos de stall dos dois níveis de cache e o CPI básico:
Portanto, o processador com a cache secundária é mais rápido por um fator
de
Como alternativa, poderíamos ter calculado os ciclos de stall somando os ciclos de stall das referências que acertam na cache secundária ((2% – 0,5%) × 20 = 0,3) e as referências que vão à memória principal, que precisam incluir o custo para acessar a cache secundária, bem como o tempo de acesso à memória principal (0,5% × (20 + 400) = 2,1). A soma, 1,0 + 0,3 + 2,1, é novamente 3,4. As considerações de projeto para uma cache primária e secundária são significativamente diferentes porque a presença da outra cache muda a melhor escolha em comparação com uma cache de nível único. Em especial, uma estrutura de cache de dois níveis permite que a cache primária se concentre em minimizar o tempo de acerto para produzir um ciclo de clock mais curto, enquanto permite que a cache secundária focalize a taxa de falhas no sentido de reduzir a penalidade dos longos tempos de acesso à memória. O efeito dessas mudanças nas duas caches pode ser visto comparando cada cache com o projeto otimizado para um nível único de cache. Em comparação com uma cache de nível único, a cache primária de uma cache multinível normalmente é menor. Além disso, a cache primária frequentemente usa um tamanho de bloco menor, para se adequar ao tamanho de cache menor e à penalidade de falha reduzida. Em comparação, a cache secundária normalmente será maior do que em uma cache de nível único, já que o tempo de acesso da cache secundária é menos importante. Com um tamanho total maior, a cache secundária pode usar um tamanho de bloco maior do que o apropriado com uma cache de nível único. Ela constantemente utiliza uma associatividade maior que a cache primária, dado o foco da redução de taxas de falha.
cache multinível Uma hierarquia de memória com múltiplos níveis de cache, em vez de apenas uma cache e a memória principal.
Entendendo o desempenho dos programas A classificação tem sido exaustivamente analisada para se encontrar algoritmos melhores: Bubble Sort, Quicksort e assim por diante. A Figura 5.19(a) mostra as instruções executadas por item pesquisado pelo Radix Sort em comparação com o Quicksort. Decididamente, para arrays grandes, o Radix Sort possui uma vantagem algorítmica sobre o Quicksort em termos do número de operações. A Figura 5.19(b) mostra o tempo por chave, em vez das instruções executadas. Podemos ver que as linhas começam na mesma trajetória da Figura 5.19(a), mas, então, a linha do Radix Sort diverge conforme os dados a serem ordenados aumentam. O que está ocorrendo? A Figura 5.19(c) responde olhando as falhas de cache por item ordenado: o Quicksort possui muito menos falhas por item a ser ordenado. Infelizmente, a análise algorítmica padrão ignora o impacto da hierarquia de memória. À medida que velocidades de clock mais altas e a Lei de Moore permitem aos arquitetos compactarem todo o desempenho de um fluxo de instruções, um uso correto da hierarquia de memória é fundamental para que se obtenha um alto desempenho. Como dissemos na introdução, entender o comportamento da hierarquia de memória é vital para compreender o desempenho dos programas nos computadores atuais.
FIGURA 5.19 Comparando o Quicksort e o Radix Sort por (a) instruções executadas por item ordenado, (b) tempo por item ordenado e (c) falhas de cache por item ordenado. Esses dados são de um artigo de LaMarca e Ladner [1996]. Devido a esses resultados, foram criadas novas versões do Radix Sort que levam a hierarquia de memória em consideração, para readquirir suas vantagens logarítmicas (Seção 5.13). A ideia básica das otimizações de cache é usar todos os dados em um bloco repetidamente antes de serem substituídos em uma falha.
Otimização de software por bloqueio Dada a importância da hierarquia de memória para o desempenho do programa, não é surpresa o fato de que foram criadas muitas otimizações de software que podem melhorar drasticamente o desempenho reutilizando dados dentro da cache e, portanto, ocasionar menos taxas de perda, devido à melhor localidade temporal. Ao lidarmos com arrays, podemos obter um bom desempenho do sistema de memória se armazenarmos o array na memória de modo que os acessos ao array
sejam sequenciais na memória. Mas suponha que estejamos lidando com múltiplos arrays, com alguns arrays acessados por linhas e alguns por colunas. Armazenar os arrays linha por linha (ordem principal de linha) ou coluna por coluna (ordem principal de coluna) não resolve o problema, pois linhas e colunas são usadas em cada iteração do loop. Ao invés de operar sobre linhas ou colunas inteiras de um array, os algoritmos bloqueados operam sobre submatrizes ou blocos. O objetivo é maximizar os acessos aos dados carregados na cache antes que esses dados sejam substituídos; ou seja, melhorar a localidade temporal para reduzir as falhas de cache. Por exemplo, os loops mais internos do DGEMM (linhas de 4 a 9 da Figura 3.21, no Capítulo 3) são
Os dois loops lêem todos os elementos N por N de B, lêem os mesmos N elementos em uma linha de A repetidamente, e escrevem o que corresponde a uma linha de N elementos de C. Os comentários facilitam a identificação das linhas e colunas das matrizes. A Figura 5.20 contém um instantâneo dos acessos aos três arrays. Um tom cinza escuro indica um acesso recente, um tom cinza claro indica um acesso mais antigo, e branco significa ainda não acessado.
FIGURA 5.20 Um instantâneo dos três arrays C, A e B quando N = 6 e i = 1. A idade dos acessos aos elementos do array é indicada pelo tom: branco significa ainda não tocado, cinza claro significa acessos mais antigos, e cinza escuro significa acessos mais recentes. Em comparação com a Figura 5.22, os elementos de A e B são lidos repetidamente para calcular novos elementos de x. As variáveis i, j e k aparecem ao longo das linhas ou colunas usadas para acessar os arrays.
O número de perdas de capacidade depende claramente de N e do tamanho da cache. Se ele puder manter todas as três matrizes N por N, então tudo está bem, desde que não existam conflitos de cache. Propositalmente, escolhemos o tamanho de matriz como 32 por 32 no DGEMM para os Capítulos 3 e 4, de modo que esse seria o caso. Cada matriz tem 32 × 32 = 1024 elementos e cada elemento tem 8 bytes, de modo que as três matrizes ocupam 24 KiB, que são suficientes para caber na cache de dados de 32 KiB do Intel Core i7 (Sandy Bridge). Se a cache puder manter uma matriz N por N e uma linha de N, então pelo menos a “iésima” linha de A e o array B podem permanecer na cache. Menos do que isso e perdas poderão ocorrer para B e C. No pior dos casos, haveria 2N3 + N2 palavras de memória acessadas para N3 operações. Para garantir que os elementos sendo acessados podem caber na cache, o código original é mudado para calcular em uma submatriz. Logo, basicamente chamamos a versão de DGEMM da Figura 4.80, no Capítulo 4, repetidamente nas matrizes de tamanho BLOCKSIZE por BLOCKSIZE. BLOCKSIZE é chamado de fator de bloqueio. A Figura 5.21 mostra a versão de bloqueio do DGEMM. A função do_block é o DGEMM da Figura 3.21 com três novos parâmetros, si, sj e sk para especificar a posição inicial de cada submatriz de A, B e C. O otimizador gcc
remove qualquer overhead de chamada de função, colocando a função “em linha”; ou seja, ele insere o código diretamente, para evitar as instruções convencionais de passagem de parâmetros e manutenção do endereço de retorno.
FIGURA 5.21 Versão bloqueada por cache do DGEMM da Figura 3.21. Suponha que C seja inicializado em zero. A função do_block é basicamente o DGEMM do Capítulo 3 com alguns novos parâmetros para especificar as posições iniciais das submatrizes de BLOCKSIZE. O otimizador gcc pode remover as instruções de overhead de função colocando a função do_block em linha.
A Figura 5.22 ilustra os acessos aos três arrays usando o bloqueio. Vendo apenas as perdas de capacidade, o número total de palavras da memória acessadas é 2N3/BLOCKSIZE + N2. Esse total é uma melhoria por um fator de BLOCKSIZE. Logo, o bloqueio explora uma combinação de localidade espacial e temporal, pois A se beneficia com a localidade espacial e B se beneficia com a localidade temporal.
FIGURA 5.22 A idade dos acessos aos arrays C, A e B quando BLOCKSIZE = 3. Observe, em comparação com a Figura 5.20, o número menor de elementos acessados.
Embora tenhamos visado reduzir as perdas de cache, o bloqueio também pode ser usado para ajudar na alocação de registrador. Apanhando um pequeno tamanho de bloqueio, de modo que o bloco possa ser mantido nos registradores, podemos minimizar o número de loads e stores no programa, o que também melhora o desempenho. A Figura 5.23 mostra o impacto do bloqueio de cache sobre o desempenho do DGEMM não otimizado à medida que aumentamos o tamanho da matriz além do ponto em que todas as três matrizes cabem na cache. O desempenho não otimizado é dividido ao meio para a matriz maior. A versão com bloqueio de cache é menos de 10% mais lenta, mesmo em matrizes de 960 × 960, ou 900 vezes maiores que as matrizes de 32 × 32 dos Capítulos 3 e 4.
FIGURA 5.23 Desempenho do DGEMM não otimizado (Figura 3.21) contra o DGEMM bloqueado por cache (Figura 5.21) enquanto a dimensão da matriz varia de 32 × 32 (onde todas as matrizes cabem na cache) a 960 × 960.
Detalhamento Caches multiníveis envolvem diversas complicações. Primeiramente, agora existem vários tipos diferentes de falhas e taxas de falhas correspondentes. No exemplo “Falhas e associatividade nas caches”, anteriormente neste capítulo, vimos a taxa de falhas da cache primária e a taxa de falhas global — a fração das referências que falharam em todos os níveis de cache. Há também uma taxa de falhas para a cache secundária, que é a taxa de todas as falhas na cache secundária dividida pelo número de acessos. Essa taxa de falhas é chamada de taxa de falhas local da cache secundária. Como a cache primária filtra os acessos, especialmente aqueles com boa localidade espacial e temporal, a taxa de falhas local da cache secundária é muito mais alta do que a taxa de falhas global. No exemplo anterior citado, podemos calcular a taxa de falhas local da cache secundária como: 0,5%/2% = 25%! Felizmente, a taxa de falhas global determina a frequência com que precisamos acessar a memória principal.
taxa de falhas global A fração das referências que falham em todos os níveis de uma cache multinível.
taxa de falhas local A fração das referências a um nível de uma cache que falham; usada em hierarquias multiníveis.
Detalhamento Com processadores que usam execução fora de ordem (ver Capítulo 4), o desempenho é mais complexo, já que executam instruções durante a penalidade de falha. Em vez da taxa de falhas de instruções e da taxa de falhas de dados, usamos falhas por instrução e esta fórmula:
Não há uma maneira geral de calcular a latência de falha sobreposta; portanto, as avaliações das hierarquias de memória para processadores com execução fora de ordem, inevitavelmente, exigem simulações do processador e da hierarquia de memória. Somente vendo a execução do processador durante cada falha é que podemos ver se o processador sofre stall esperando os dados ou simplesmente encontra outro trabalho para fazer. Uma regra é que o processador muitas vezes oculta a penalidade de falha para uma falha de cache L1 que acerta na cache L2, mas raramente oculta uma falha para a cache L2.
Detalhamento O desafio do desempenho para algoritmos é que a hierarquia de memória varia entre diferentes implementações da mesma arquitetura no tamanho de cache, na associatividade, no tamanho de bloco e no número de caches. Para fazer frente a essa variabilidade, algumas bibliotecas numéricas recentes parametrizam os seus algoritmos e, então, pesquisam o espaço de parâmetros em tempo de execução, de modo a encontrar a melhor combinação para um determinado computador. Essa técnica é chamada de autotuning.
Verifique você mesmo Qual das afirmações a seguir geralmente é verdadeira sobre um projeto com
múltiplos níveis de cache? 1. As caches de primeiro nível são mais focalizadas no tempo de acerto, e as caches de segundo nível se preocupam mais com a taxa de falhas. 2. As caches de primeiro nível são mais focalizadas na taxa de falhas, e as caches de segundo nível se preocupam mais com o tempo de acerto.
Resumo Nesta seção, nos concentramos em três tópicos: o desempenho da cache, o uso da associatividade para reduzir as taxas de falhas, o uso das hierarquias de cache multinível para reduzir as penalidades de falha e as otimizações de software para melhorar a eficácia das caches. O sistema de memória tem um efeito significativo sobre o tempo de execução do programa. O número de ciclos de stall de memória depende da taxa de falhas e da penalidade de falha. O desafio, como veremos na Seção 5.8, é reduzir um desses fatores sem afetar significativamente os outros fatores críticos na hierarquia de memória. Para reduzir a taxa de falhas, examinamos o uso dos esquemas de posicionamento associativos. Esses esquemas podem reduzir a taxa de falhas de uma cache permitindo um posicionamento mais flexível dos blocos dentro dela. Os esquemas totalmente associativos permitem que os blocos sejam posicionados em qualquer lugar, mas também exigem que todos os blocos da cache sejam pesquisados para atender a uma requisição. Os custos mais altos tornam as caches totalmente associativas inviáveis. As caches associativas por conjunto são uma alternativa prática, já que precisamos pesquisar apenas entre os elementos de um único conjunto, escolhido por indexação. As caches associativas por conjunto apresentam taxas de falhas mais altas, porém são mais rápidas de serem acessadas. O grau de associatividade que produz o melhor desempenho depende da tecnologia e dos detalhes da implementação. Examinamos as caches multiníveis como uma técnica para reduzir a penalidade de falha permitindo que uma cache secundária maior trate das falhas na cache primária. As caches de segundo nível se tornaram comuns quando os projetistas descobriram que o silício limitado e as metas de altas velocidades de clock impedem que as caches primárias se tornem grandes. A cache secundária, que normalmente é 10 ou mais vezes maior do que a cache primária, trata muitos acessos que falham na cache primária. Nesses casos, a penalidade de falha é aquela do tempo de acesso à cache secundária (em geral, menos de dez ciclos de
processador) contra o tempo de acesso à memória (normalmente mais de 100 ciclos de processador). Assim como na associatividade, as negociações de projeto entre o tamanho da cache secundária e seu tempo de acesso dependem de vários aspectos de implementação. Finalmente, dada a importância da hierarquia de memória no desempenho, vimos como alterar os algoritmos para melhorar o comportamento da cache, sendo que o bloqueio é uma técnica importante quando se lida com grandes arrays.
5.5. Hierarquia de memória estável Em toda a discussão anterior está implícito que a hierarquia de memória não se esquece. Se fosse rápida mas não confiável, ela não seria muito atraente. Como vimos no Capítulo 1, a grande ideia para a estabilidade é a redundância. Nesta seção, primeiro daremos uma passada pelos termos para defini-los assim como as medidas associadas à falha, e depois mostraremos como a redundância pode criar memórias quase inesquecíveis.
Definição de falha Começamos com uma suposição de que você possui uma especificação de serviço adequado. Os usuários podem então ver um sistema alternando entre dois estados de serviço entregue com relação à especificação do serviço: 1. Realização de serviço, onde o serviço é entregue conforme especificado 2. Interrupção de serviço, onde o serviço entregue é diferente do serviço especificado As transições do estado 1 para o estado 2 são causadas por falhas e as
transições do estado 2 para o estado 1 são chamadas de restaurações. As falhas podem ser permanentes ou intermitentes. Este último é o caso mais difícil; é mais difícil diagnosticar o problema quando o sistema oscila entre os dois estados. As falhas permanentes são muito mais fáceis de diagnosticar. Essa definição leva a dois termos relacionados: confiabilidade e disponibilidade. Confiabilidade é uma medida da realização contínua do serviço — ou, de modo equivalente, do tempo para a falha — a partir de um ponto de referência. Logo, tempo médio para a falha (MTTF — Mean Time To Failure) é uma medida de confiabilidade. Um termo relacionado é a taxa de falhas anual (AFR — Annual Failure Rate), que é simplesmente a porcentagem de dispositivos que se espera falhar em um ano para determinado MTTF. Quando o MTTF fica grande, ele pode ser enganoso, enquanto a AFR leva a uma intuição melhor.
MTTF versus AFR de discos Exemplo Alguns discos hoje são classificados como tendo um MTTF de 1.000.000 horas. Como 1.000.000 horas é 1.000.000/(365 × 24) = 114 anos, pode parecer que eles praticamente nunca falham. Computadores em escala gigantesca, que rodam serviços para a Internet, como os de busca, podem ter 50.000 servidores. Suponha que cada servidor tenha 2 discos. Use a AFR para calcular quantos discos esperaríamos que falhem por ano.
Resposta Um ano é 365 × 24 = 8760 horas. Um MTTF de 1.000.000 horas significa uma AFR de 8760/1.000.000 = 0,876%. Com 100.000 discos, esperaríamos que 876 discos falhem por ano, ou uma média de mais de 2 discos falhando por dia! A interrupção de serviço é medida como o tempo médio para o reparo (MTTR — Mean Time To Repair). Tempo médio entre falhas (MTBF — Mean Time Between Failures) é simplesmente a soma de MTTF + MTTR. Embora o MTBF seja bastante usado, o MTTF geralmente é o termo mais apropriado. A disponibilidade é então uma medida da realização de serviço com relação à alternância entre os dois estados de realização e interrupção. A disponibilidade é
estatisticamente quantificada como
Observe que a confiabilidade e a disponibilidade são realmente medidas quantificáveis, em vez de apenas sinônimos de estabilidade. Encurtar o MTTR pode ajudar a disponibilidade e também aumentar o MTTF. Por exemplo, ferramentas para detecção, diagnóstico e reparo de falhas podem ajudar a reduzir o tempo para o reparo de falhas e, portanto, melhorar a disponibilidade. Queremos que a disponibilidade seja muito alta. Uma abreviação é indicar o número de “noves de disponibilidade” por ano. Por exemplo, um serviço de Internet muito bom hoje oferece 4 ou 5 noves de disponibilidade. Com 365 por ano, que significa 365 × 24 × 60 = 526.000 minutos, então a abreviação é decodificada da seguinte forma: Um nove:
90%
=> 36,5 dias de reparo/ano
Dois noves:
99%
=> 3,65 dias de reparo/ano
Três noves:
99,9%
=> 526 minutos de reparo por ano
Quatro noves: 99,99% Cinco noves:
=> 52,6 minutos de reparo por ano
99,999% => 5,26 minutos de reparo por ano
e assim por diante. Para aumentar o MTTF, você pode melhorar a qualidade dos componentes ou sistemas de projeto para continuar a operação na presença de componentes que falharam. Logo, a falha precisa ser definida em relação a um contexto, pois a falha de um componente pode não levar a uma falha do sistema. Para tornar essa distinção clara, o termo falha é usado para significar a falha de um componente. Aqui estão três maneiras de melhorar o MTTF: 1. Impedimento de falha: Impedir a ocorrência de falha pela construção. 2. Tolerância a falha: Usar redundância para permitir que o serviço cumpra a especificação do serviço apesar da ocorrência de falhas. 3. Previsão de falha: Prever a presença e a criação de falhas, permitindo que o componente seja substituído antes de falhar.
O código Single Error Correcting, Double Error Detecting (SEC/DED) de Hamming Richard Hamming inventou um esquema popular de redundância para a memória, para o qual recebeu o Turing Award em 1968. Para inventar códigos redundantes, é útil falar sobre a “proximidade” dos padrões de bit corretos. Chamamos de distância de Hamming simplesmente o número mínimo de bits que são diferentes entre dois padrões de bit corretos quaisquer. Por exemplo, a distância entre 011011 e 001111 é dois. O que acontece se a distância mínima entre os membros de um código for dois e obtivermos um erro de um bit? Isso tornará um padrão válido em um código para um padrão inválido. Assim, se pudermos detectar se os membros de um código são válidos ou não, poderemos detectar erros de único bit, e podemos dizer que temos um código de detecção de erro de único bit.
código de detecção de erro
Um código que permite a detecção de um erro nos dados, mas não o local exato e, portanto, a correção do erro. Hamming usou um código de paridade para a detecção de erro. Em um código de paridade, o número de 1s em uma palavra é contado; a palavra tem paridade ímpar se o número de 1s for ímpar e par, em caso contrário. Quando uma palavra é escrita na memória, o bit de paridade também é escrito (1 para ímpar, 0 para par). Ou seja, a paridade da palavra de N + 1 bits sempre deverá ser par. Então, quando a palavra é lida, o bit de paridade é lido e verificado. Se a paridade da palavra de memória e o bit de paridade armazenado não combinarem, isso significa que houve um erro.
Exemplo Calcule a paridade de um byte com o valor 31dec e mostre o padrão armazenado na memória. Suponha que o bit de paridade esteja à direita. Suponha que o bit mais significativo fosse invertido na memória, e então você o lê de volta. Você detectou o erro? O que acontece se os dois bits mais significativos forem invertidos?
Resposta 31dec é 00011111bin, que tem cinco 1s. Para tornar a paridade par, precisamos escrever um 1 no bit de paridade, ou 000111111bin. Se o bit mais significativo fosse invertido quando o lêssemos de volta, veríamos 100111111bin, que tem sete 1s. Como esperamos a paridade par e calculamos paridade ímpar, sinalizaríamos um erro. Se os dois bits mais significativos fossem invertidos, veríamos 110111111bin, que tem oito 1s e paridade par; neste caso, não sinalizaríamos um erro. Se houver 2 bits com erro, então um esquema de paridade de 1 bit não detectará erro algum, pois a paridade será a mesma nos dados contendo dois bits errados. (Na verdade, um esquema de paridade de 1 bit pode detectar qualquer número ímpar de erros; porém, a probabilidade de ter 3 erros é muito menor do que a probabilidade de ter dois, de modo que, na prática, um código de paridade de 1 bit é limitado a detectar um único bit errado.) Naturalmente, um código de paridade não pode corrigir erros, o que Hamming
queria fazer além de detectá-los. Se usássemos um código contendo uma distância mínima de 3, então qualquer erro de único bit estaria mais próximo do padrão correto do que de qualquer outro padrão válido. Ele sugeriu um mapeamento de dados fácil de entender para um código com distância 3, que chamamos Código de Correção de Erro (ECC) de Hamming, em sua homenagem. Usamos os bits de paridade extras para permitir a identificação da posição de um erro isolado. Aqui estão as etapas para se calcular o ECC de Hamming: 1. Comece numerando os bits a partir de 1 na esquerda, ao contrário da numeração tradicional, onde o bit mais à direita é o 0. 2. Marque todas as posições de bit que são potências de 2 como bits de paridade (posições 1, 2, 4, 8, 16, ...). 3. Todas as outras posições de bit são usadas para bits de dados (posições 3, 5, 6, 7, 9, 10, 11, 12, 13, 14, 15, …). 4. A posição do bit de paridade, que determina a sequência de bits de dados que ele verifica (a Figura 5.24 mostra essa explicação graficamente), é: ▪ Bit 1 (0001bin) verifica os bits (1,3,5,7,9,11,…), que são os bits onde o bit mais à direita do endereço é 1 (0001bin, 0011bin, 0101bin, 0111bin, 1001bin, 1011bin,…). ▪ Bit 2 (0010bin) verifica os bits (2,3,6,7,10,11,14,15,…), que são os bits onde o segundo bit à direita no endereço é 1. ▪ Bit 4 (0100bin) verifica os bits (4–7, 12–15, 20–23,…), que são os bits onde o terceiro bit à direita no endereço é 1. ▪ Bit 8 (1000bin) verifica os bits (8–15, 24–31, 40–47,…), que são os bits onde o quarto bit à direita no endereço é 1. Observe que cada bit de dados é coberto por dois ou mais bits de paridade. 5. Defina os bits de paridade para criar paridade par para cada grupo.
FIGURA 5.24 Bits de paridade, bits de dados e cobertura de campo em um código ECC de Hamming para oito bits de dados.
No que parece ser um truque de mágica, você pode então determinar se os bits estão incorretos examinando os bits de paridade. Usando o código de 12 bits da Figura 5.24, se o valor dos quatro cálculos de paridade (p8,p4,p2,p1) fosse 0000, então não haveria erro. Porém, se o padrão fosse, digamos, 1010, que é 10dec, então o ECC de Hamming nos diz que o bit 10 (d6) tem um erro. Como o número é binário, podemos corrigir o erro simplesmente invertendo o valor do bit 10.
Exemplo Suponha que o valor de um byte de dados seja 10011010bin. Primeiro, mostre o código ECC de Hamming para esse byte e depois inverta o bit 10 e mostre que o código ECC encontra e corrige o erro no único bit.
Resposta Deixando espaços para os bits de paridade, o padrão de 12 bits é _ _ 1_ 0 0 1_ 1 0 1 0. A posição 1 verifica os bits 1,3,5,7,9 e 11, destacados a seguir: __ 1_ 0 0 1_ 1 0 1 0. Para tornar a paridade do grupo par, devemos definir o bit 1 como 0. A posição 2 verifica os bits 2,3,6,7,10,11, que é 0 _ 1_ 0 0 1_ 1 0 1 0, ou paridade ímpar, de modo que definimos a posição 2 como 1. A posição 4 verifica os bits 4,5,6,7,12, que é 0 1 1 _ 0 0 1_ 1 0 1, de modo que o definimos como 1. A posição 8 verifica os bits 8,9,10,11,12, que é 0 1 1 1 0 0 1 _ 1 0 1 0, de modo que o definimos como 0. A palavra de código final é 011100101010. A inversão do bit 10 muda a
palavra para 011100101110. O bit de paridade 1 é 0 (011100101110 tem quatro 1s, paridade par; este grupo está OK). O bit de paridade 2 é 1 (011100101110 tem cinco 1s, paridade ímpar; há um erro em algum lugar). O bit de paridade 4 é 1 (011100101110 tem dois 1s, paridade par; este grupo está OK). O bit de paridade 8 é 1 (011100101110 tem três 1s, paridade ímpar; há um erro em algum lugar). Os bits de paridade 2 e 8 estão incorretos. Como 2 + 8 = 10, o bit 10 deverá estar errado. Logo, podemos corrigir o erro invertendo o bit 10: 011100101010. Pronto! Hamming não parou no código de correção de erro de único bit. Com o custo de mais um bit, podemos fazer com que a distância mínima de Hamming em um código seja 4. Isso significa que podemos corrigir erros de único bit e detectar erros de duplo bit. A ideia é acrescentar um bit de paridade que seja calculado sobre toda a palavra. Vamos usar uma palavra de dados de quatro bits como um exemplo, que só precisaria de 7 bits para uma detecção de erro de único bit. Os bits de paridade de Hamming H (p1 p2 p3) são calculados (paridade par, como sempre), mais a paridade par sobre a palavra inteira, p4: 1 2 3 4 5 6 7 8 p1 p2 d1 p3 d2 d3 d4 p4 Então, o algoritmo para corrigir um erro e detectar dois é simplesmente calcular a paridade sobre os grupos do ECC (H) como antes e mais um sobre o grupo inteiro (p4). Existem quatro casos: 1. H é par e p4 é par; portanto, não houve erro. 2. H é ímpar e p4 é ímpar, de modo que houve um único erro corrigível. (p4 deveria calcular paridade ímpar se houvesse um erro). 3. H é par e p4 é ímpar; houve um único erro em p4, e não no restante da palavra, de modo que corrigimos o bit p4. 4. H é ímpar e p4 é par; houve um erro duplo. (p4 deveria calcular paridade par se houvesse dois erros.) Single Error Correcting / Double Error Detecting (SEC/DED) é comum na memória de servidores hoje em dia. De modo conveniente, blocos de dados com oito bytes podem obter SEC/DED com apenas um byte a mais, motivo pelo qual
muitas DIMMs possuem 72 bits de largura.
Detalhamento Para calcular quantos bits são necessários para SEC, imagine que p seja o número total de bits de paridade e d o número de bits de dados na palavra de p + d bits. Se p bits de correção de erro tiverem que apontar para o bit de erro (p + d casos) mais um caso para indicar que não existe erro, precisamos de:
Por exemplo, para dados de 8 bits isso significa d = 8 e 2p ≥ p + 8 + 1, de modo que p = 4. De modo semelhante, p = 5 para 16 bits de dados, 6 para 32 bits, 7 para 64 bits, e assim por diante.
Detalhamento Em sistemas muito grandes, a possibilidade de erros múltiplos, bem como a falha completa de um único chip de memória largo, torna-se significativa. A IBM introduziu o Chipkill para resolver esse problema, e muitos sistemas grandes utilizam essa tecnologia. (A Intel chama sua versão de SDDC.) Semelhante por natureza à técnica de RAID usada para discos, chipkill distribui os dados e informações de ECC, de modo que a falha completa de um único chip de memória pode ser tratada com o suporte à reconstrução dos dados que faltam a partir dos chips de memória restantes. Considerando um cluster de 10.000 processadores com 4 GiB por processador, a IBM calculou as seguintes taxas de erro de memória irrecuperável em três anos de operação: ▪ Paridade apenas — cerca de 90.000, ou uma falha irrecuperável (ou não detectada) a cada 17 minutos. ▪ SEC/DED apenas — cerca de 3500, ou aproximadamente uma falha não detectada ou irrecuperável a cada 7,5 horas. ▪ Chipkill — 6, ou cerca de uma falha não detectada ou irrecuperável a cada 2 meses. Logo, Chipkill é um requisito para computadores em escala gigantesca.
Detalhamento
Detalhamento Embora erros de um ou dois bits sejam comuns para sistemas de memória, as redes podem ter rajadas de erros de bit. Uma solução é denominada Cyclic Redundancy Check (verificação de redundância cíclica). Para um bloco de k bits, um transmissor gera uma sequência de verificação de quadro de n–k bits. Ele transmite n bits exatamente divisíveis por algum número. O receptor divide o quadro por esse número. Se não houver resto, ele considera que não há erro. Se houver, o receptor rejeita a mensagem e pede ao transmissor para enviar novamente. Como você pode imaginar pelo Capítulo 3, é fácil calcular a divisão por alguns números binários com um registrador de deslocamento, o que torna os códigos CRC populares até mesmo quando o hardware era mais precioso. Seguindo mais à frente, os códigos Reed-Solomon utilizam campos de Galois para corrigir erros de transmissão em múltiplos bits, mas agora os dados são considerados coeficientes de um polinômio e o espaço de código são os valores de um polinômio. O cálculo Reed-Solomon é, muitas vezes, mais complicado do que a divisão binária!
5.6. Máquinas virtuais Máquinas Virtuais (VM — Virtual Machines) foram desenvolvidas inicialmente em meados da década de 1960, e continuaram sendo uma parte importante da computação de grande porte com o passar dos anos. Embora bastante ignoradas na era do PC monousuário, durante as décadas de 1980 e 1990, elas obtiveram popularidade recentemente, devido aos seguintes fatores: ▪ O aumento de importância do isolamento e da segurança nos sistemas modernos ▪ As falhas na segurança e na confiabilidade dos sistemas operacionais padrão ▪ O compartilhamento de um único computador entre muitos usuários não relacionados, particularmente para a computação em nuvem ▪ Os aumentos fantásticos na velocidade bruta dos processadores no decorrer das décadas, tornando mais aceitável o overhead das VMs A definição mais geral das VMs inclui basicamente todos os métodos de emulação que oferecem uma interface de software padrão, como a Java VM. Nesta seção, estamos interessados nas VMs que oferecem um ambiente completo em nível de sistema, no nível da arquitetura de conjunto de instruções (ISA) binária. Embora algumas VMs excutem diferentes ISAs na VM do hardware nativo, consideramos que elas sempre correspondem ao hardware.
Essas VMs são chamadas de (Operating) System Virtual Machines — máquinas virtuais do sistema (operacional). Alguns exemplos são IBM VM/370, VirtualBox, VMware ESX Server e Xen. As máquinas virtuais do sistema apresentam a ilusão de que os usuários têm um computador inteiro para si, incluindo uma cópia do sistema operacional. Um único computador executa várias VMs e pode aceitar diversos sistemas operacionais (OSs) diferentes. Em uma plataforma convencional, um único OS “possui” todos os recursos do hardware, mas, com uma VM, vários OSs compartilham os recursos do hardware. O software que dá suporte às VMs é chamado de monitor de máquina virtual (VMM — Virtual Machine Monitor), ou hipervisor; o VMM é o centro da tecnologia de máquina virtual. A plataforma de hardware básica é chamada de host e seus recursos são compartilhados entre as VMs guest. O VMM determina como mapear recursos virtuais a recursos físicos: um recurso físico pode ser de tempo compartilhado, particionado ou ainda simulado no software. O VMM é muito menor que um OS tradicional; a parte de isolamento de um VMM possui talvez apenas 10.000 linhas de código. Embora nosso interesse aqui seja as VMs para melhorar a proteção, elas oferecem dois outros benefícios que são comercialmente significativos: 1. Gerenciar o software. As VMs oferecem uma abstração que pode executar uma pilha de software completa, incluindo até mesmo sistemas operacionais antigos, como o DOS. Uma implantação típica poderia ser algumas VMs executando OSs legados, muitas executando a versão atual estável do OS, e algumas testando a próxima versão do OS. 2. Gerenciar o hardware. Um motivo para servidores múltiplos é ter cada aplicação executando com a versão compatível do sistema operacional em computadores separados, pois essa separação pode melhorar a confiabilidade. As VMs permitem que essas pilhas de software separadas sejam executadas independentemente enquanto compartilham o hardware, consolidando assim o número de servidores. Outro exemplo é que alguns VMMs admitem a migração de uma VM atual para um computador diferente, seja no sentido de balancear a carga ou sair do hardware com falha.
Interface Hardware/Software Amazon Web Services (AWS) utiliza as máquinas virtuais em sua plataforma
de computação em nuvem, oferecendo EC2 por cinco razões: 1. Isso permite que a AWS proteja os usuários um do outro, enquanto compartilham o mesmo servidor. 2. Isso simplifica a distribuição de software dentro de um computador em escala gigantesca. Um cliente instala uma imagem de máquina virtual configurada com o software apropriado, e a AWS a distribui para todas as instâncias que um cliente quiser usar. 3. Os clientes (e a AWS) podem “matar” uma VM de modo confiável, para controlar o uso de recursos quando os clientes terminarem seu trabalho. 4. As máquinas virtuais ocultam a identidade do hardware em que o cliente está executando, o que significa que a AWS pode continuar usando servidores antigos e introduzir novos servidores, mais eficientes. O cliente espera que o desempenho para as instâncias corresponda às suas avaliações em “Unidades de Computação EC2”, que a AWS define como: “fornecer a capacidade de CPU equivalente de um processador AMD Opteron 2007 de 1,0 a 1,2 GHz ou Intel Xeon 2007”. Graças à Lei de Moore, servidores mais novos claramente oferecem mais Unidades de Computação EC2 do que os mais antigos, mas a AWS pode continuar alugando servidores antigos, desde que sejam econômicos. 5. Os Monitores de Máquina Virtual podem controlar a velocidade com que a VM usa o processador, a rede e o espaço em disco, permitindo que a AWS ofereça muitos pontos com preço de instâncias de diferentes tipos executando nos mesmos servidores subjacentes. Por exemplo, em 2012, a AWS oferecia 14 tipos de instância, desde pequenas instâncias padrão a US$ 0,08 por hora a instâncias quádruplas extra grandes com alta E/S a US$ 3,10 por hora.
Em geral, o custo da virtualização do processador depende da carga de trabalho. Os programas ligados ao processador em nível de usuário possuem overhead de virtualização zero, pois o OS raramente é chamado, de modo que tudo é executado nas velocidades nativas. As cargas de trabalhos com uso intenso de E/S em geral usam intensamente o OS, executando muitas chamadas do sistema e instruções privilegiadas, o que pode resultar em um alto overhead de virtualização. Por outro lado, se a carga de trabalho com uso intenso de E/S também for voltada para E/S, o custo da virtualização do processador pode ser completamente ocultado, pois o processador geralmente está ocioso, esperando pela E/S. O overhead é determinado pelo número de instruções que devem ser simuladas pelo VMM e por quanto tempo cada uma precisa simular. Logo, quando as VMs guest executam a mesma ISA que o host, como consideramos aqui, o objetivo da arquitetura e do VMM é executar quase todas as instruções diretamente no hardware nativo.
Requisitos de um monitor de máquina virtual O que um monitor de VM precisa fazer? Ele apresenta uma interface de software ao software guest, precisa isolar o estado dos guests um do outro e precisa proteger-se contra o software guest (incluindo os OSs guest). Os requisitos
qualitativos são: ▪ O software guest deverá se comportar em uma VM exatamente como se estivesse sendo executado no hardware nativo, exceto pelo comportamento relacionado ao desempenho ou limitações de recursos fixos compartilhados por múltiplas VMs. ▪ O software guest não deverá alterar diretamente a alocação de recursos reais do sistema. Para “virtualizar” o processador, o VMM precisa controlar praticamente tudo — acesso ao estado privilegiado, tradução de endereços, E/S, exceções e interrupções — embora a VM guest e o OS atualmente em execução estejam temporariamente utilizando-os. Por exemplo, no caso de uma interrupção de um temporizador, a VMM suspenderia a VM guest atualmente em execução, salvaria seu estado, trataria da interrupção, determinaria qual VM guest será executada em seguida e, depois, carregaria seu estado. As VMs guest que contam com uma interrupção de temporizador recebem um temporizador virtual e uma interrupção de temporizador simulada pelo VMM. Para estar no controle, o VMM precisa estar em um nível de privilégio mais alto que a VM guest, que geralmente é executada no modo usuário; isso também garante que a execução de qualquer instrução privilegiada será tratada pelo VMM. Os requisitos básicos do sistema de máquina virtual: ▪ Pelo menos dois modos de processador, sistema e usuário. ▪ Um subconjunto de instruções privilegiado, que está disponível apenas no modo do sistema, resultado em um trap se executado no modo usuário; todos os recursos do sistema precisam ser controláveis apenas por meio dessas instruções.
(Falta de) Suporte da arquitetura do conjunto de instruções para máquinas virtuais Se as VMs forem planejadas durante o projeto da ISA, será relativamente fácil reduzir o número de instruções que devem ser executadas por um VMM e sua velocidade de simulação. Uma arquitetura que permite que a VM seja executada diretamente no hardware recebe o título de virtualizável, e a arquitetura IBM 370 orgulhosamente ostenta esse rótulo. Contudo, como as VMs foram consideradas para aplicações de servidor e PC apenas recentemente, a maioria dos conjuntos de instruções foi criada sem a
virtualização em mente. Esses culpados incluem a x86 e a maioria das arquiteturas RISC, incluindo ARMv7 e MIPS. Como o VMM precisa garantir que o sistema guest só interaja com recursos virtuais, um OS guest convencional é executado como um programa no modo usuário em cima do VMM. Então, se um OS guest tentar acessar ou modificar informações relacionadas aos recursos do hardware por meio de uma instrução privilegiada (por exemplo, lendo ou escrevendo um bit de status que habilita interrupções), isso será interceptado pelo VMM. O VMM poderá então efetuar as mudanças apropriadas nos recursos reais correspondentes. Portanto, se qualquer instrução que tenta ler ou escrever essas informações sensíveis for interceptada quando executada no modo usuário, o VMM poderá interceptá-la e dar suporte a uma versão virtual da informação sensível, conforme o OS guest espera. Na ausência desse suporte, outras medidas deverão ser tomadas. Um VMM precisa tomar precauções especiais para localizar todas as instruções problemáticas e garantir que elas se comportem corretamente quando executadas por um OS guest, aumentando assim a complexidade do VMM e reduzindo o desempenho da execução da VM.
Proteção e arquitetura do conjunto de instruções Proteção é um esforço conjunto da arquitetura e dos sistemas operacionais, mas os arquitetos tiveram de modificar alguns detalhes desajeitados das arquiteturas de conjunto de instruções existentes quando a memória virtual se tornou popular. Por exemplo, a instrução POPF do x86 carrega os registradores de flag do topo da pilha para a memória. Um dos flags é o flag Interrupt Enable (IE). Se você executar a instrução POPF no modo usuário, em vez de interceptá-la, ela simplesmente muda todos os flags exceto IE. No modo do sistema, ela muda o IE. Como um OS guest é executado no modo usuário dentro de uma VM, isso é um problema, pois espera ver um flag IE alterado. Historicamente, o hardware mainframe IBM e o VMM exigiam três etapas para melhorar o desempenho das máquinas virtuais: 1. Reduzir o custo da virtualização do processador. 2. Reduzir o custo de overhead da interrupção devido à virtualização. 3. Reduzir o custo da interrupção direcionando as interrupções para a VM apropriada sem chamar o VMM. Em 2006, novas propostas da AMD e Intel tentaram resolver o primeiro
ponto, reduzindo o custo da virtualização do processador. Será interessante ver quantas gerações de arquitetura e modificações do VMM serão necessárias para resolver todos os três pontos, e quanto tempo passará antes que as máquinas virtuais do século XXI sejam tão eficientes quanto os mainframes IBM e VMMs da década de 1970.
5.7. Memória virtual …foi inventado um sistema para fazer a combinação entre os sistemas centrais de memória e os tambores de discos aparecer para o programador como um depósito de nível único, com as transferências necessárias ocorrendo automaticamente. Kilburn et al., One-level storage system, 1962
Nas seções anteriores, vimos como as caches fornecem acesso rápido às partes recentemente usadas do código e dos dados de um programa. Da mesma forma, a memória principal pode agir como uma “cache” para o armazenamento secundário, normalmente implementado com discos magnéticos. Essa técnica é chamada de memória virtual. Historicamente, houve duas motivações principais para a memória virtual: permitir o compartilhamento seguro e eficiente da memória entre vários programas, removendo os transtornos de programação de uma quantidade pequena e limitada de memória principal. Cinco décadas após sua invenção, o primeiro motivo é o que ainda predomina.
memória virtual Uma técnica que usa a memória principal como uma “cache” para armazenamento secundário. É claro que, para permitir que várias máquinas virtuais compartilhem a mesma memória, precisamos ser capazes de proteger essas VMs umas das outras, garantindo que um programa só possa ler e escrever as partes da memória principal atribuídas a ele. A memória principal precisa conter apenas as partes ativas das muitas máquinas virtuais, exatamente como uma cache contém apenas a parte ativa de um programa. Portanto, o princípio da localidade possibilita a memória virtual e as caches, e a memória virtual nos permite compartilhar eficientemente o processador e a memória principal. Não podemos saber quais máquinas virtuais irão compartilhar a memória com outras máquinas virtuais quando compilamos seus programas. Na verdade, as máquinas virtuais que compartilham a memória mudam dinamicamente enquanto estão sendo executadas. Devido a essa interação dinâmica, gostaríamos
de compilar cada programa para o seu próprio espaço de endereçamento — faixa distinta dos locais de memória acessível apenas a esse programa. A memória virtual implementa a tradução do espaço de endereçamento de um programa para os endereços físicos. Esse processo de tradução impõe a proteção do espaço de endereçamento de um programa contra outras máquinas virtuais.
endereço físico Um endereço na memória principal.
proteção Um conjunto de mecanismos para garantir que múltiplos processos compartilhando processador, memória ou dispositivos de E/S não possam interferir, intencionalmente ou não, um com o outro, lendo ou escrevendo dados um do outro. Esses mecanismos também isolam o sistema operacional de um processo de usuário. A segunda motivação para a memória virtual é permitir que um único programa do usuário exceda o tamanho da memória principal. Antigamente, se um programa se tornasse muito grande para a memória, cabia ao programador fazê-lo se adequar. Os programadores dividiam os programas em partes e, então, identificavam aquelas mutuamente exclusivas. Esses overlays eram carregados ou descarregados sob o controle do programa do usuário durante a execução, com o programador garantindo que o programa nunca tentaria acessar um overlay que não estivesse carregado e que os overlays carregados nunca excederiam o tamanho total da memória. Os overlays eram tradicionalmente organizados como módulos, cada um contendo código e dados. As chamadas entre procedimentos em módulos diferentes levavam um módulo a se sobrepor a outro. Como você pode bem imaginar, essa responsabilidade era uma carga substancial para os programadores. A memória virtual, criada para aliviar os programadores dessa dificuldade, gerencia automaticamente os dois níveis da hierarquia de memória representados pela memória principal (às vezes, chamada de memória física para distingui-la da memória virtual) e pelo armazenamento secundário. Embora os conceitos aplicados na memória virtual e nas caches sejam os mesmos, suas diferentes raízes históricas levaram ao uso de uma terminologia
diferente. Um bloco de memória virtual é chamado de página, e uma falha da memória virtual é chamada de falta de página. Com a memória virtual, o processador produz um endereço virtual, traduzido por uma combinação de hardware e software para um endereço físico, que, por sua vez, pode ser usado de modo a acessar a memória principal. A Figura 5.25 mostra a memória endereçada virtualmente com páginas mapeadas na memória principal. Esse processo é chamado de mapeamento de endereço ou tradução de endereço. Hoje, os dois níveis de hierarquia de memória controlados pela memória virtual são as DRAMs e a memória flash nos dispositivos móveis pessoais, e as DRAMs e os discos magnéticos nos servidores (Seção 5.2). Se voltarmos à nossa analogia da biblioteca, podemos pensar no endereço virtual como o título de um livro e no endereço físico como seu local na biblioteca.
FIGURA 5.25 Na memória virtual, os blocos de memória (chamados de páginas) são mapeados de um conjunto de endereços (chamados de endereços virtuais) para outro conjunto (chamado de endereços físicos). O processador gera endereços virtuais enquanto a memória é acessada usando endereços físicos. Tanto a memória virtual quanto a memória física são desmembradas em páginas, de
modo que uma página virtual é realmente mapeada em uma página física. Naturalmente, também é possível que uma página virtual esteja ausente da memória principal e não seja mapeada para um endereço físico, residindo no disco em vez disso. As páginas físicas podem ser compartilhadas fazendo dois endereços virtuais apontarem para o mesmo endereço físico. Essa capacidade é usada para permitir que dois programas diferentes compartilhem dados ou código.
falta de página Um evento que ocorre quando uma página acessada não está presente na memória principal.
endereço virtual Um endereço que corresponde a um local no espaço virtual e é traduzido pelo mapeamento de endereço para um endereço físico quando a memória é acessada.
tradução de endereço Também chamada de mapeamento de endereço. O processo pelo qual um endereço virtual é mapeado a um endereço usado para acessar a memória. A memória virtual também simplifica o carregamento do programa para execução fornecendo relocação. A relocação mapeia os endereços virtuais usados por um programa para diferentes endereços físicos antes que os endereços sejam usados no acesso à memória. Essa relocação nos permite carregar o programa em qualquer lugar na memória principal. Além disso, todos os sistemas de memória virtual em uso atualmente relocam o programa como um conjunto de blocos (páginas) de tamanho fixo, eliminando, assim, a necessidade de encontrar um bloco contíguo de memória para alocar um programa; em vez disso, o sistema operacional só precisa encontrar um número suficiente de páginas na memória principal. Na memória virtual, o endereço é desmembrado em um número de página virtual e um offset de página. A Figura 5.26 mostra a tradução do número de página virtual para um número de página física. O número de página física
constitui a parte mais significativa do endereço físico, enquanto o offset de página, que não é alterado, constitui a parte menos significativa. O número de bits no campo offset de página determina o tamanho da página. O número de páginas endereçáveis com o endereço virtual não precisa corresponder ao número de páginas endereçáveis com o endereço físico. Ter um número de páginas virtuais maior do que as páginas físicas é a base para a ilusão de uma quantidade de memória virtual essencialmente ilimitada.
FIGURA 5.26 Mapeamento de um endereço virtual em um endereço físico. O tamanho de página é 212 = 4KB. O número de páginas físicas permitido na memória é 218, já que o número de página física contém 18 bits. Portanto, a memória principal pode ter, no máximo, 1GB, enquanto o espaço de endereço virtual possui 4GB.
Muitas escolhas de projeto nos sistemas de memória virtual são motivadas pelo alto custo de uma falta de página. Uma falta de página em disco levará milhões de ciclos de clock para ser processada. (A tabela na Seção 5.1 mostra
que a latência da memória principal é aproximadamente 100.000 vezes mais rápida do que o disco.) Essa enorme penalidade de falha, dominada pelo tempo para obter a primeira palavra para tamanhos de página típicos, leva a várias decisões importantes nos sistemas de memória virtual: ▪ As páginas devem ser grandes o suficiente para tentar amortizar o longo tempo de acesso. Tamanhos de 4 KiB a 16 KiB são comuns atualmente. Novos sistemas de desktop e servidor estão sendo desenvolvidos para suportar páginas de 32 KiB e 64 KiB, embora novos sistemas embutidos estejam indo na outra direção, para páginas de 1 KiB. ▪ Organizações que reduzem a taxa de faltas de página são atraentes. A principal técnica usada aqui é permitir o posicionamento totalmente associativo das páginas na memória. ▪ As faltas de página podem ser tratadas em nível de software porque o overhead será pequeno se comparado com o tempo de acesso ao disco. Além disso, o software pode se dar ao luxo de usar algoritmos inteligentes para escolher como posicionar as páginas, já que mesmo pequenas reduções na taxa de falhas compensarão o custo desses algoritmos. ▪ O write-through não funcionará para a memória virtual, visto que as escritas levam muito tempo. Em vez disso, os sistemas de memória virtual usam write-back. As próximas subseções tratam desses fatores no projeto de memória virtual.
Detalhamento Apresentamos a motivação para a memória virtual como muitas máquinas virtuais compartilhando a mesma memória, mas a memória virtual foi inventada originalmente de modo que muitos programas pudessem compartilhar um computador como parte de um sistema de tempo compartilhado. Como muitos leitores hoje não possuem experiência com sistemas de tempo compartilhado, usamos as máquinas virtuais para motivar esta seção.
Detalhamento Para computadores servidores e desktops, processadores de 32 bits já são problemáticos. Embora normalmente imaginemos os endereços virtuais como muito maiores do que os endereços físicos, o contrário pode ocorrer quando o
tamanho de endereço do processador é pequeno em relação ao estado da tecnologia de memória. Nenhum programa ou máquina virtual isolada pode se beneficiar, mas um grupo de programas executados ao mesmo tempo pode se beneficiar de não precisar ser trocado para a memória ou de ser executado em processadores paralelos.
Detalhamento A discussão da memória virtual neste livro focaliza na paginação, que usa blocos de tamanho fixo. Há também um esquema de blocos de tamanho variável chamado segmentação. Na segmentação, um endereço consiste em duas partes: um número de segmento e um offset de segmento. O registrador de segmento é mapeado a um endereço físico e o offset é somado para encontrar o endereço físico real. Como o segmento pode variar em tamanho, uma verificação de limites é necessária para garantir que o offset esteja dentro do segmento. O principal uso da segmentação é suportar métodos de proteção mais avançados e compartilhar um espaço de endereçamento. A maioria dos livros de sistemas operacionais contém extensas discussões sobre a segmentação comparada com a paginação e sobre o uso da segmentação para compartilhar logicamente o espaço de endereçamento. A principal desvantagem da segmentação é que ela divide o espaço de endereçamento em partes logicamente separadas que precisam ser manipuladas como um endereço de duas partes: o número de segmento e o offset. A paginação, por outro lado, torna o limite entre o número de página e o offset invisível aos programadores e compiladores.
segmentação Um esquema de mapeamento de endereço de tamanho variável em que um endereço consiste em duas partes: um número de segmento, que é mapeado para um endereço físico, e um offset de segmento. Os segmentos também têm sido usados como um método para estender o espaço de endereçamento sem mudar o tamanho da palavra do computador. Essas tentativas têm sido malsucedidas devido à dificuldade e ao ônus de desempenho inerentes a um endereço de duas partes, dos quais os programadores e compiladores precisam estar cientes.
Muitas arquiteturas dividem o espaço de endereçamento em grandes blocos de tamanho fixo que simplificam a proteção entre o sistema operacional e os programas de usuário e aumentam a eficiência da implementação da paginação. Embora essas divisões normalmente sejam chamadas de “segmentos”, esse mecanismo é muito mais simples do que a segmentação de tamanho de bloco variável e não é visível aos programas do usuário; discutiremos o assunto em mais detalhes em breve.
Posicionando uma página e a encontrando novamente Em razão da penalidade incrivelmente alta decorrente de uma falta de página, os projetistas reduzem a frequência das faltas de página otimizando seu posicionamento. Se permitirmos que uma página virtual seja mapeada em qualquer página física, o sistema operacional, então, pode escolher substituir qualquer página que desejar quando ocorrer uma falta de página. Por exemplo, o sistema operacional pode usar um sofisticado algoritmo e complexas estruturas de dados, que monitoram o uso de páginas, para tentar escolher uma página que não será necessária por um longo tempo. A capacidade de usar um esquema de substituição inteligente e flexível reduz a taxa de faltas de página e simplifica o uso do posicionamento de páginas totalmente associativo. Como mencionamos na Seção 5.4, a dificuldade em usar posicionamento totalmente associativo está em localizar uma entrada, já que ela pode estar em qualquer lugar no nível superior da hierarquia. Uma pesquisa completa é impraticável. Nos sistemas de memória virtual, localizamos páginas usando uma tabela que indexa a memória; essa estrutura é chamada de tabela de páginas e reside na memória. Uma tabela de páginas é indexada pelo número de página do endereço virtual para descobrir o número da página física correspondente. Cada programa possui sua própria tabela de páginas, que mapeia o espaço de endereçamento virtual desse programa para a memória principal. Em nossa analogia da biblioteca, a tabela de páginas corresponde a um mapeamento entre os títulos dos livros e os locais da biblioteca. Exatamente como o catálogo de cartões pode conter entradas para livros em outra biblioteca ou campus em vez da biblioteca local, veremos que a tabela de páginas pode conter entradas para páginas não presentes na memória. A fim de indicar o local da tabela de páginas na memória, o hardware inclui um registrador que aponta para o início da tabela de páginas; esse registrador é chamado de registrador de tabela de páginas. Por
enquanto, considere que a tabela de páginas esteja em uma área fixa e contígua da memória.
tabela de páginas A tabela com as traduções de endereço virtual para físico em um sistema de memória virtual. A tabela, armazenada na memória, normalmente é indexada pelo número de página virtual; cada entrada na tabela contém o número da página física para essa página virtual se a página estiver atualmente na memória.
Interface hardware/software A tabela de páginas, juntamente com o contador de programa e os registradores, especifica o estado de uma máquina virtual. Se quisermos permitir que outra máquina virtual use o processador, precisamos salvar esse estado. Mais tarde, após restaurar esse estado, a máquina virtual pode continuar a execução. Frequentemente nos referimos a esse estado como um processo. O processo é considerado ativo quando está de posse do processador; caso contrário, ele é considerado inativo. O sistema operacional pode ativar um processo carregando o estado do processo, incluindo o contador de programa, o que irá iniciar a execução no valor salvo do contador de programa. O espaço de endereçamento do processo e, consequentemente, todos os dados que ele pode acessar na memória, é definido pela sua tabela de páginas, que reside na memória. Em vez de salvar a tabela de páginas inteira, o sistema operacional simplesmente carrega o registrador de tabela de páginas de modo a apontar para a tabela de páginas do processo que ele quer tornar ativo. Cada processo possui sua própria tabela de páginas, já que diferentes processos usam os mesmos endereços virtuais. O sistema operacional é responsável por alocar a memória física e atualizar as tabelas de páginas, de modo que os espaços de endereço virtuais dos diferentes processos não colidam. Como veremos em breve, o uso de tabelas de páginas separadas também fornece proteção de um processo contra outro. A Figura 5.27 usa o registrador de tabela de páginas, o endereço virtual e a tabela de páginas indicada para mostrar como o hardware pode formar um
endereço físico. Um bit de validade é usado em cada entrada de tabela de páginas, exatamente como faríamos em uma cache. Se o bit estiver desligado, a página não está presente na memória principal e ocorre uma falta de página. Se o bit estiver ligado, a página está na memória e a entrada contém o número de página física.
FIGURA 5.27 A tabela de páginas é indexada pelo número de página virtual para obter a parte correspondente do endereço físico. Consideramos um endereço de 32 bits. O endereço inicial da tabela de páginas é dado pelo ponteiro da tabela de páginas. Nessa figura, o tamanho de página é 212 bytes, ou 4 KiB. O espaço de endereço virtual é 232 bytes, ou 4 GiB, e o espaço de endereçamento físico é 230 bytes, que permite uma memória principal de até 1 GiB. O número de entradas na tabela de
páginas é 220, ou um milhão de entradas. O bit de validade para cada entrada indica se o mapeamento é válido. Se ele estiver desligado, a página não está presente na memória. Embora a entrada de tabela de páginas mostrada aqui só precise ter 19 bits de largura, ela normalmente seria arredondada para 32 bits a fim de facilitar a indexação. Os bits extras seriam usados para armazenar informações adicionais que precisam ser mantidas página a página, como a proteção.
Como a tabela de páginas contém um mapeamento para toda página virtual possível, nenhuma tag é necessária. Em terminologia de cache, o índice usado para acessar a tabela de páginas consiste no endereço de bloco inteiro, que é o número de página virtual.
Faltas de página Se o bit de validade para uma página virtual estiver desligado, ocorre uma falta de página. O sistema operacional precisa receber o controle. Essa transferência é feita pelo mecanismo de exceção, que vimos no Capítulo 4 e abordaremos mais uma vez posteriormente nesta seção. Quando o sistema operacional obtém o controle, ele precisa encontrar a página no próximo nível da hierarquia (geralmente a memória flash ou o disco magnético) e decidir onde colocar a página requisitada na memória principal. O endereço virtual por si só não diz imediatamente onde está a página no disco. Voltando à nossa analogia da biblioteca, não podemos encontrar o local de um livro nas estantes apenas sabendo seu título. Em vez disso, precisamos ir ao catálogo e consultar o livro, obter um endereço para o local nas estantes. Da mesma forma, em um sistema de memória virtual, é necessário monitorar o local no disco de cada página em um espaço de endereçamento virtual. Como não sabemos antecipadamente quando uma página na memória será escolhida para ser substituída, o sistema operacional normalmente cria o espaço na memória flash ou no disco para todas as páginas de um processo no momento em que ele cria o processo. Esse espaço é chamado de área de swap. Nesse momento, o sistema operacional também cria uma estrutura para registrar onde cada página virtual está armazenada no disco. Essa estrutura de dados pode ser parte da tabela de páginas ou pode ser uma estrutura de dados auxiliar indexada da mesma maneira que a tabela de páginas. A Figura 5.28 mostra a organização quando uma única tabela contém o número de página física ou o endereço de disco.
FIGURA 5.28 A tabela de páginas mapeia cada página na memória virtual em uma página na memória principal ou em uma página armazenada em disco, que é o próximo nível na hierarquia. O número de página virtual é usado para indexar a tabela de páginas. Se o bit de validade estiver ligado, a tabela de páginas fornece o número de página física (ou seja, o endereço inicial da página na memória) correspondente à página virtual. Se o bit de validade estiver desligado, a página reside atualmente apenas no disco, em um endereço de disco especificado. Em muitos sistemas, a tabela de endereços de página física e endereços de página de disco, embora sendo logicamente uma única tabela, é armazenada em duas estruturas de dados separadas. As tabelas duplas se justificam, em parte, porque precisamos manter os endereços de disco de todas as páginas, mesmo que elas estejam atualmente na memória principal. Lembre-se de que as páginas na memória principal e as páginas no disco são idênticas em tamanho.
área de swap O espaço no disco reservado para o espaço de memória virtual completo de um processo. O sistema operacional também cria uma estrutura de dados que controla quais processos e quais endereços virtuais usam cada página física. Quando ocorre uma falta de página, se todas as páginas na memória principal estiverem em uso, o sistema operacional precisa escolher uma página para substituir. Como queremos minimizar o número de faltas de página, a maioria dos sistemas operacionais tenta escolher uma página que supostamente não será necessária no futuro próximo. Usando o passado para prever o futuro, os sistemas operacionais seguem o esquema de substituição LRU (Least Recently Used — usado menos recentemente), que mencionamos na Seção 5.4. O sistema operacional procura a página usada menos recentemente, fazendo a suposição de que uma página que não foi usada por um longo período é menos provável de ser usada do que uma página acessada mais recentemente. As páginas substituídas são escritas na área de swap do disco. Caso você esteja curioso, o sistema operacional é apenas outro processo, e essas tabelas controlando a memória estão na memória; os detalhes dessa aparente contradição serão explicados em breve.
Interface hardware/software Implementar um esquema de LRU completamente preciso é muito dispendioso, pois requer atualizar uma estrutura de dados a cada referência à memória. Como alternativa, a maioria dos sistemas operacionais aproxima a LRU monitorando que páginas foram e que páginas não foram usadas recentemente. Para ajudar o sistema operacional a estimar as páginas LRU, alguns computadores fornecem um bit de referência ou bit de uso, que é ligado sempre que uma página é acessada. O sistema operacional limpa periodicamente os bits de referência e, depois, os registra para que ele possa determinar quais páginas foram tocadas durante um determinado período. Com essas informações de uso, o sistema operacional pode selecionar uma página que está entre as referenciadas menos recentemente (detectadas tendo seu bit de referência desligado). Se esse bit não for fornecido pelo hardware, o sistema operacional precisará encontrar outra maneira de calcular quantas páginas foram acessadas.
bit de referência Também chamado de bit de uso. Um campo que é ligado sempre quando uma página é acessada e usado para implementar LRU ou outros esquemas de substituição.
Detalhamento Com um endereço virtual de 32 bits, páginas de 4 KiB e 4 bytes por entrada da tabela de páginas, podemos calcular o tamanho total da tabela de páginas:
Ou seja, precisaríamos usar 4 MiB da memória para cada programa em execução em um dado momento. Essa quantidade não é ruim para um único processo. Mas, e se houver centenas de processos rodando, cada um com sua própria tabela de página? E como devemos tratar endereços de 64 bits, que por esse cálculo precisariam de 252 palavras? Diversas técnicas são usadas no sentido de reduzir a quantidade de armazenamento necessária para a tabela de páginas. As cinco técnicas a seguir visam reduzir o armazenamento máximo total necessário, bem como minimizar a memória principal dedicada às tabelas de páginas: 1. A técnica mais simples é manter um registrador de limite que restrinja o tamanho da tabela de páginas para um determinado processo. Se o número de página virtual se tornar maior do que o conteúdo do registrador de limite, entradas precisarão ser incluídas na tabela de páginas. Essa técnica permite que a tabela de páginas cresça à medida que um processo consome mais espaço. Assim, a tabela de páginas só será maior se o processo estiver usando muitas páginas do espaço de endereçamento virtual. Essa técnica exige que o espaço de
endereçamento se expanda apenas em uma direção. 2. Permitir o crescimento apenas em uma direção não é o bastante, já que a maioria das linguagens exige duas áreas cujo tamanho seja expansível: uma área contém a pilha e a outra contém o heap. Devido a essa dualidade, é conveniente dividir a tabela de páginas e deixá-la crescer do endereço mais alto para baixo, assim como do endereço mais baixo para cima. Isso significa que haverá duas tabelas de páginas separadas e dois limites separados. O uso de duas tabelas de páginas divide o espaço de endereçamento em dois segmentos. O bit mais significativo de um endereço normalmente determina que segmento — e, portanto, que tabela de páginas — deve ser usado para esse endereço. Como o segmento é especificado pelo bit de endereço mais significativo, cada segmento pode ter a metade do tamanho do espaço de endereçamento. Um registrador de limite para cada segmento especifica o tamanho atual do segmento, que cresce em unidades de páginas. Esse tipo de segmentação é usado por muitas arquiteturas, inclusive MIPS. Diferente do tipo de segmentação abordado na seção “Detalhamento” anterior, essa forma de segmentação é invisível ao programa de aplicação, embora não para o sistema operacional. A principal desvantagem desse esquema é que ele não funciona bem quando o espaço de endereçamento é usado de uma maneira esparsa e não como um conjunto contíguo de endereços virtuais. 3. Outro método para reduzir o tamanho da tabela de páginas é aplicar uma função de hashing no endereço virtual de modo que a estrutura de dados da tabela de páginas precise ser apenas do tamanho do número de páginas físicas na memória principal. Essa estrutura é chamada de tabela de páginas invertida. É claro que o processo de consulta é um pouco mais complexo com uma tabela de páginas invertida, já que não podemos mais simplesmente indexar a tabela de páginas. 4. Múltiplos níveis de tabelas de páginas também podem ser usados no sentido de reduzir a quantidade total de armazenamento para a tabela de páginas. O primeiro nível mapeia grandes blocos de tamanho fixo do espaço de endereçamento virtual, talvez de 64 a 256 páginas no total. Esses grandes blocos são, às vezes, chamados de segmentos, e essa tabela de mapeamento de primeiro nível é chamada de tabela de segmentos, embora os segmentos sejam invisíveis ao usuário. Cada entrada na tabela de segmentos indica se alguma página neste segmento
está alocada e, se estiver, aponta para uma tabela de páginas desse segmento. A tradução de endereços ocorre primeiramente olhando na tabela de segmentos, usando os bits de mais alta ordem do endereço. Se o endereço do segmento for válido, o próximo conjunto de bits mais significativos é usado para indexar a tabela de páginas indicada pela entrada da tabela de segmentos. Esse esquema permite que o espaço de endereçamento seja usado de uma maneira esparsa (vários segmentos não contíguos podem estar ativos), sem precisar alocar a tabela de páginas inteira. Esses esquemas são particularmente úteis com espaços de endereçamento muito grandes e em sistemas de software que exigem alocação não contígua. A principal desvantagem desse mapeamento de dois níveis é o processo mais complexo para a tradução de endereços. 5. A fim de reduzir a memória principal real consumida pelas tabelas de páginas, a maioria dos sistemas modernos também permite que as tabelas de páginas sejam paginadas. Embora isso pareça complicado, esse esquema funciona usando os mesmos conceitos básicos da memória virtual e simplesmente permite que as tabelas de páginas residam no espaço de endereçamento virtual. Entretanto, há alguns problemas pequenos mas cruciais, como uma série interminável de faltas de página, que precisam ser evitadas. A forma como esses problemas são resolvidos é um tema muito detalhado e, em geral, altamente específico ao processador. Em poucas palavras, esses problemas são evitados colocando todas as tabelas de páginas no espaço de endereçamento do sistema operacional e colocando pelo menos algumas das tabelas de páginas para o sistema operacional em uma parte da memória principal que é fisicamente endereçada e está sempre presente — e, portanto, nunca no disco.
E quanto às escritas? A diferença entre o tempo de acesso à cache e à memória principal é de dezenas a centenas de ciclos, e os esquemas write-through podem ser usados, embora precisemos de um buffer de escrita para ocultar do processador a latência da escrita. Em um sistema de memória virtual, as escritas no próximo nível de hierarquia (disco) levam milhões de ciclos de clock de processador; portanto, construir um buffer de escrita para permitir que o sistema escreva diretamente no disco seria impraticável. Em vez disso, os sistemas de memória virtual precisam
usar write-back, realizando as escritas individuais para a página na memória e copiando a página novamente para o disco quando ela é substituída na memória.
Interface hardware/software Um esquema write-back possui outra importante vantagem em um sistema de memória virtual. Como o tempo de transferência do disco é pequeno comparado com seu tempo de acesso, copiar de volta uma página inteira é muito mais eficiente do que escrever palavras individuais novamente no disco. Uma operação write-back, embora mais eficiente do que transferir páginas individuais, ainda é onerosa. Portanto, gostaríamos de saber se uma página precisa ser copiada de volta quando escolhemos substituí-la. Para monitorar se uma página foi escrita desde que foi lida para a memória, um bit de modificação (dirty bit) é acrescentado à tabela de páginas. O bit de modificação é ligado quando qualquer palavra em uma página é escrita. Se o sistema operacional escolher substituir a página, o bit de modificação indica se a página precisa ser escrita no disco antes que seu local na memória possa ser cedido a outra página. Logo, uma página modificada normalmente é chamada de “dirty page”.
Tornando a tradução de endereços rápida: a TLB Como as tabelas de páginas são armazenadas na memória principal, cada acesso à memória por um programa pode levar, no mínimo, o dobro do tempo: um acesso à memória para obter o endereço físico e um segundo acesso para obter os dados. O segredo para melhorar o desempenho de acesso é basear-se na localidade da referência à tabela de páginas. Quando uma tradução para um número de página virtual é usada, ela provavelmente será necessária novamente no futuro próximo, pois as referências às palavras nessa página possuem localidade temporal e também espacial. Assim, os processadores modernos incluem uma cache especial que controla as traduções usadas recentemente. Essa cache especial de tradução de endereços é tradicionalmente chamada de TLB (translation-lookaside buffer), embora seria mais correto chamá-la de cache de tradução. A TLB corresponde àquele pequeno pedaço de papel que normalmente usamos para registrar o local de um conjunto de livros que consultamos no catálogo; em vez de pesquisar continuamente o catálogo inteiro, registramos o local de vários livros e usamos o
pedaço de papel como uma cache da biblioteca.
TLB (Translation-Lookaside Buffer) Uma cache que monitora os mapeamentos de endereços recentemente usados para evitar um acesso à tabela de páginas. A Figura 5.29 mostra que cada entrada de tag na TLB contém uma parte do número de página virtual, e cada entrada de dados da TLB contém um número de página física. Como não iremos mais acessar a tabela de páginas a cada referência, em vez disso acessaremos a TLB, que precisará incluir outros bits de status, como o bit de modificação e o bit de referência.
FIGURA 5.29 A TLB age como uma cache da tabela de páginas apenas para as entradas que mapeiam as páginas físicas. A TLB contém um subconjunto dos mapeamentos de página virtual para física que estão na tabela de páginas. Os mapeamentos da TLB são mostrados em destaque. Como a TLB
é uma cache, ela precisa ter um campo tag. Se não houver uma entrada correspondente na TLB para uma página, a tabela de páginas precisa ser examinada. A tabela de páginas fornece um número de página física para a página (que pode, então, ser usado na construção de uma entrada da TLB) ou indica que a página reside em disco, caso em que ocorre uma falta de página. Como a tabela de páginas possui uma entrada para cada página virtual, nenhum campo tag é necessário; ou seja, ela não é uma cache.
Em cada referência, consultamos o número de página virtual na TLB. Se tivermos um acerto, o número de página física é usado para formar o endereço e o bit de referência correspondente é ligado. Se o processador estiver realizando uma escrita, o bit de modificação também é ligado. Se ocorrer uma falha na TLB, precisamos determinar se ela é uma falta de página ou simplesmente uma falha de TLB. Se a página existir na memória, então a falha de TLB indica apenas que a tradução está faltando. Nesse caso, o processador pode tratar a falha de TLB lendo a tradução da tabela de páginas para a TLB e, depois, tentando a referência novamente. Se a página não estiver presente na memória, então a falha de TLB indica uma falta de página verdadeira. Nesse caso, o processador chama o sistema operacional usando uma exceção. Como a TLB possui muito menos entradas do que o número de páginas na memória principal, as falhas de TLB serão muito mais frequentes do que as faltas de página verdadeiras. As falhas de TLB podem ser tratadas no hardware ou no software. Na prática, com cuidado, pode haver pouca diferença de desempenho entre os dois métodos, uma vez que as operações básicas são iguais nos dois casos. Depois que uma falha de TLB tiver ocorrido e a tradução faltando tiver sido recuperada da tabela de páginas, precisaremos selecionar uma entrada da TLB para substituir. Como os bits de referência e de modificação estão contidos na entrada da TLB, precisamos copiar esses bits de volta para a entrada da tabela de páginas quando substituirmos uma entrada. Esses bits são a única parte da entrada da TLB que pode ser modificada. O uso de write-back — ou seja, copiar de volta essas entradas no momento da falha e não quando são escritas — é muito eficiente, já que esperamos que a taxa de falhas da TLB seja pequena. Alguns sistemas usam outras técnicas para aproximar os bits de referência e de modificação, eliminando a necessidade de escrever na TLB, exceto para carregar uma nova entrada da tabela em caso de falha. Alguns valores comuns para uma TLB poderiam ser:
▪ Tamanho da TLB: 16 a 512 entradas. ▪ Tamanho do bloco: uma a duas entradas da tabela de páginas (geralmente 4 a 8 bytes cada uma). ▪ Tempo de acerto: 0,5 a 1 ciclo de clock ▪ Penalidade de falha: 10 a 100 ciclos de clock ▪ Taxa de falhas: 0,01% a 1% Os projetistas têm usado uma ampla gama de associatividades em TLBs. Alguns sistemas usam TLBs pequenas e totalmente associativas porque um mapeamento totalmente associativo possui uma taxa de falhas mais baixa; além disso, como a TLB é pequena, o custo de um mapeamento totalmente associativo não é tão alto. Outros sistemas usam TLBs grandes, normalmente com pequena associatividade. Com um mapeamento totalmente associativo, escolher a entrada a ser substituída se torna difícil, pois é muito caro implementar um esquema de LRU de hardware. Além do mais, como as falhas de TLB são muito mais frequentes do que as faltas de página e, portanto, precisam ser tratadas de modo mais econômico, não podemos utilizar um algoritmo de software caro, como para as falhas. Como resultado, muitos sistemas fornecem algum suporte para escolher aleatoriamente uma entrada a ser substituída. Veremos os esquemas de substituição mais detalhadamente na Seção 5.8.
A TLB do Intrinsity FastMATH Para ver essas ideias em um processador real, vamos dar uma olhada mais de perto na TLB do Intrinsity FastMATH. O sistema de memória usa páginas de 4 KiB e um espaço de endereçamento de 32 bits; portanto, o número de página virtual tem 20 bits de extensão, como no alto da Figura 5.30. O endereço físico é do mesmo tamanho do endereço virtual. A TLB contém 16 entradas, é totalmente associativa e é compartilhada entre as referências de instruções e de dados. Cada entrada possui 64 bits de largura e contém uma tag de 20 bits (que é o número de página virtual para essa entrada de TLB), o número de página física correspondente (também 20 bits), um bit de validade, um bit de modificação e outros bits de contabilidade. Como na maioria dos sistemas MIPS, ela utiliza software para tratar das falhas de TLB.
FIGURA 5.30 A TLB e a cache implementam o processo de ir de um endereço virtual para um item de dados no Intrinsity FastMATH. Esta figura mostra a organização da TLB e a cache de dados considerando um tamanho de página de 4 KiB. Este diagrama focaliza uma leitura; a Figura 5.31 descreve como tratar escritas. Repare que, diferente da Figura 5.12, as RAMs de tag e de dados são divididas. Endereçando a longa, mas estreita, RAM de dados com o índice de cache concatenado com o offset de bloco, selecionamos a palavra desejada no bloco sem um multiplexador 16:1. Embora a cache seja diretamente mapeada, a TLB é totalmente associativa. A implementação de uma TLB totalmente associativa exige que toda tag TLB seja comparada com o número de página virtual, já que a entrada desejada pode
estar em qualquer lugar na TLB. (Ver memórias endereçáveis por conteúdo na seção “Detalhamento” da seção “Localizando um bloco na cache”.) Se o bit de validade da entrada correspondente estiver ligado, o acesso será um acerto de TLB e os bits do número de página física acrescidos aos bits do offset da página formarão o índice usado para acessar a cache.
A Figura 5.30 mostra a TLB e uma das caches, enquanto a Figura 5.31 mostra as etapas no processamento de uma requisição de leitura ou escrita. Quando ocorre uma falha de TLB, o hardware MIPS salva o número de página da referência em um registrador especial e gera uma exceção. A exceção chama o sistema operacional, que trata a falha no software. Para encontrar o endereço físico da página ausente, a rotina de falha de TLB indexa a tabela de páginas usando o número de página do endereço virtual e o registrador de tabela de páginas, que indica o endereço inicial da tabela de páginas do processo ativo. Usando um conjunto especial de instruções de sistema que podem atualizar a TLB, o sistema operacional coloca o endereço físico da tabela de páginas na TLB. Uma falha de TLB leva cerca de 13 ciclos de clock, considerando que o código e a entrada da tabela de páginas estejam na cache de instruções e na cache de dados, respectivamente. (Veremos o código TLB MIPS mais adiante). Uma falta de página verdadeira ocorre se a entrada da tabela de páginas não possuir um endereço físico válido. O hardware mantém um índice que indica a entrada recomendada a ser substituída, escolhida aleatoriamente.
FIGURA 5.31 Processando uma leitura ou uma escrita direta na TLB e na cache do Intrinsity FastMATH. Se a TLB gerar um acerto, a cache pode ser acessada com o endereço físico resultante. Para uma leitura, a cache gera um acerto ou uma falha e fornece os dados ou causa um stall enquanto os dados são trazidos da memória. Se a operação for uma escrita, uma parte da entrada de cache é substituída por um acerto e os dados são enviados ao buffer de escrita se considerarmos uma cache write-through. Uma falha de escrita é exatamente como uma falha de leitura exceto que o bloco é modificado após ser lido da memória. Uma cache write-back requer que as escritas liguem um dirty bit para o bloco de cache; além disso, um buffer de escrita é carregado com o bloco inteiro apenas em uma falha de leitura ou falha de escrita se o bloco a ser substituído estiver com o dirty bit ligado. Observe que um acerto de TLB e um acerto de cache são eventos independentes, mas um acerto de cache só pode ocorrer após um acerto de TLB, o que significa que os dados precisam estar presentes na
memória. A relação entre as falhas de TLB e as falhas de cache é examinada mais a fundo no exemplo a seguir e nos exercícios no final do capítulo.
Existe uma complicação extra para requisições de escrita: o bit de acesso de escrita na TLB precisa ser verificado. Esse bit impede que o programa escreva em páginas para as quais tenha apenas acesso de leitura. Se o programa tentar uma escrita e o bit de acesso de escrita estiver desligado, uma exceção é gerada. O bit de acesso de escrita faz parte do mecanismo de proteção, que abordaremos em breve.
Integrando memória virtual, TLBs e caches Nossos sistemas de memória virtual e de cache funcionam em conjunto como uma hierarquia, de modo que os dados não podem estar na cache a menos que estejam presentes na memória principal. O sistema operacional desempenha um importante papel na manutenção dessa hierarquia removendo o conteúdo de qualquer página da cache quando decide migrar essa página para o disco. Ao mesmo tempo, o sistema operacional modifica as tabelas de páginas e a TLB de modo que uma tentativa de acessar quaisquer dados na página migrada gere uma falta de página. Sob as circunstâncias ideais, um endereço virtual é traduzido pela TLB e enviado para a cache em que os dados apropriados são encontrados, recuperados e devolvidos ao processador. No pior caso, uma referência pode falhar em todos os três componentes da hierarquia de memória: a TLB, a tabela de páginas e a cache. O exemplo a seguir ilustra essas interações em mais detalhes.
Operação geral de uma hierarquia de memória Exemplo Em uma hierarquia de memória como a da Figura 5.30, que inclui uma TLB e uma cache organizada como mostrado, uma referência de memória pode encontrar três tipos de falhas diferentes: uma falha de TLB, uma falta de página e uma falha de cache. Considere todas as combinações desses três eventos com uma ou mais ocorrendo (sete possibilidades). Para cada possibilidade, diga se esse evento realmente pode ocorrer e sob que circunstâncias.
Resposta
Resposta A Figura 5.32 mostra as circunstâncias possíveis e se elas podem ou não surgir na prática.
FIGURA 5.32 As possíveis combinações de eventos na TLB, no sistema de memória virtual e na cache. Três dessas combinações são impossíveis e uma é possível (acerto de TLB, acerto de memória virtual, falha de cache), mas nunca detectada.
Detalhamento A Figura 5.32 considera que todos os endereços de memória são traduzidos para endereços físicos antes que a cache seja acessada. Nessa organização, a cache é fisicamente indexada e fisicamente rotulada (tanto o índice quanto a tag de cache são endereços físicos em vez de virtuais). Nesse sistema, a quantidade de tempo para acessar a memória, considerando um acerto de cache, precisa acomodar um acesso de TLB e um acesso de cache; naturalmente, esses acessos podem ser em pipeline. Como alternativa, o processador pode indexar a cache com um endereço que seja completa ou parcialmente virtual. Isso é chamado de cache virtualmente endereçada e usa tags que são endereços virtuais; portanto, esse tipo de cache é virtualmente indexado e virtualmente rotulado. Nestas caches, o hardware de tradução de endereço (TLB) não é usado durante o acesso de cache normal, já que a cache é acessada com um endereço virtual que não foi traduzido para um endereço físico. Isso tira a TLB do caminho crítico, reduzindo a latência da cache. Quando ocorre uma falha de cache, no entanto, o processador precisa traduzir o endereço para um endereço físico de modo que ele possa buscar o bloco de cache da memória principal.
cache virtualmente endereçada Uma cache acessada com um endereço virtual em vez de um endereço físico. Quando a cache é acessada com um endereço virtual e páginas são compartilhadas entre processos (que podem acessá-las com diferentes endereços virtuais), há a possibilidade de aliasing. O aliasing ocorre quando o mesmo objeto possui dois nomes — nesse caso, dois endereços virtuais para a mesma página. Esta ambiguidade cria um problema porque uma palavra nessa página pode ser colocada na cache em dois locais diferentes, cada um correspondendo a diferentes endereços virtuais. Essa ambiguidade permitiria que um programa escrevesse os dados sem que o outro programa soubesse que eles foram mudados. As caches endereçadas completamente por endereços virtuais apresentam limitações de projeto na cache e na TLB para reduzir o aliasing ou exigem que o sistema operacional (e possivelmente o usuário) tome ações para garantir que o aliasing não ocorra.
aliasing Uma situação em que o mesmo objeto é acessado por dois endereços; pode ocorrer na memória virtual quando existem dois endereços virtuais para a mesma página física. Uma conciliação comum entre esses dois pontos de projeto são as caches virtualmente indexadas (algumas vezes, usando apenas a parte do offset de página do endereço, que é um endereço físico, já que não é traduzida), mas usam tags físicas. Esses projetos, que são virtualmente indexados, mas fisicamente rotulados, tentam unir as vantagens de desempenho das caches virtualmente indexadas às vantagens da arquitetura mais simples de uma cache fisicamente endereçada. Por exemplo, não existe qualquer problema de aliasing nesse caso. A Figura 5.30 considerou um tamanho de página de 4 KiB, mas na realidade ela tem 16 KiB, de modo que o Intrinsity FastMATH pode usar esse truque. Para isso, é preciso haver uma cuidadosa coordenação entre o tamanho de página mínimo, o tamanho da cache e a associatividade.
cache fisicamente endereçada
Uma cache endereçada por um endereço físico.
Implementando proteção com memória virtual Talvez a função mais importante da memória virtual atualmente seja permitir o compartilhamento de uma única memória principal por diversos processos, enquanto fornece proteção de memória entre esses processos e o sistema operacional. O mecanismo de proteção precisa garantir que, embora vários processos estejam compartilhando a mesma memória principal, um processo rebelde não pode escrever no espaço de endereçamento de outro processo do usuário ou no sistema operacional, intencionalmente ou não. O bit de acesso de escrita na TLB pode proteger uma página de ser escrita. Sem esse nível de proteção, os vírus de computador seriam ainda mais comuns.
Interface hardware/software Para permitir que o sistema operacional implemente proteção no sistema de memória virtual, o hardware precisa fornecer pelo menos três capacidades básicas, resumidas a seguir. Observe que as duas primeiras são os mesmos requisitos necessários para as máquinas virtuais (Seção 5.6). 1. Suportar pelo menos dois modos que indicam se o processo em execução é de usuário ou de sistema operacional, normalmente chamado de processo supervisor, processo de kernel ou processo executivo. 2. Fornecer uma parte do estado do processador que um processo de usuário pode ler, mas não escrever. Isso inclui o bit de modo usuário/supervisor, que determina se o processador está no modo usuário ou supervisor, o ponteiro para a tabela de páginas e a TLB. Para escrever esses elementos, o sistema operacional usa instruções especiais que só estão disponíveis no modo supervisor. 3. Fornecer mecanismos pelos quais o processador pode passar do modo usuário para o modo supervisor e vice-versa. A primeira direção normalmente é conseguida por uma exceção de chamada ao sistema, implementada como uma instrução especial (syscall no conjunto de instruções MIPS) que transfere o controle para um local dedicado no espaço de código supervisor. Como em qualquer outra exceção, o contador de programa do ponto da chamada de sistema é salvo no PC de
exceção (EPC), e o processador é colocado no modo supervisor. Para retornar ao modo usuário da exceção, use a instrução return from exception (ERET), que retorna ao modo usuário e desvia para o endereço no EPC.
modo supervisor Também chamado de modo de kernel. Um modo que indica que um processo executado é um processo do sistema operacional.
chamada ao sistema Uma instrução especial que transfere o controle do modo usuário para um local dedicado no espaço de código supervisor, chamando o mecanismo de exceção no processo. Usando esses mecanismos e armazenando as tabelas de páginas no espaço de endereçamento do sistema operacional, este pode mudar as tabelas de páginas enquanto impede que um processo do usuário as modifique, garantindo que um processo do usuário só possa acessar o armazenamento fornecido pelo sistema operacional. Também queremos evitar que um processo leia os dados de outro processo. Por exemplo, não desejamos que o programa de um aluno leia as notas enquanto elas estiverem na memória do processador. Uma vez que começamos a compartilhar a memória principal, precisamos fornecer a capacidade de um processo proteger seus dados de serem lidos e escritos por outro processo; caso contrário, o compartilhamento da memória principal será um poço de permissividade! Lembre-se de que cada processo possui seu próprio espaço de endereçamento virtual. Portanto, se o sistema operacional mantiver as tabelas de páginas organizadas de modo que as páginas virtuais independentes mapeiem as páginas físicas separadas, um processo não será capaz de acessar os dados de outro. É claro que isso exige que um processo de usuário seja incapaz de mudar o mapeamento da tabela de páginas. O sistema operacional pode garantir segurança se ele impedir que o processo do usuário modifique suas próprias tabelas de páginas. No entanto, o sistema operacional precisa ser capaz de
modificar as tabelas de páginas. Colocar as tabelas de páginas no espaço de endereçamento protegido do sistema operacional satisfaz a ambos os requisitos. Quando os processos querem compartilhar informações de uma maneira limitada, o sistema operacional precisa assisti-los, já que o acesso às informações de outro processo exige mudar a tabela de páginas do processo que está acessando. O bit de acesso de escrita pode ser usado para restringir o compartilhamento apenas à leitura e, como o restante da tabela de páginas, esse bit pode ser mudado apenas pelo sistema operacional. Para permitir que outro processo, digamos, P1, leia uma página pertencente ao processo P2, P2 pediria ao sistema operacional para criar uma entrada na tabela de páginas para uma página virtual no espaço de endereço de P1 que aponte para a mesma página física que P2 deseja compartilhar. O sistema operacional poderia usar o bit de proteção de escrita a fim de impedir que P1 escrevesse os dados, se esse fosse o desejo de P2. Quaisquer bits que determinam os direitos de acesso a uma página precisam ser incluídos na tabela de páginas e na TLB, pois a tabela de páginas é acessada apenas em uma falha de TLB.
Detalhamento Quando o sistema operacional decide deixar de executar o processo P1 para executar o processo P2 (o que chamamos de troca de contexto ou troca de processo), ele precisa garantir que P2 não possa ter acesso às tabelas de páginas de P1, porque isso comprometeria a proteção. Se não houver uma TLB, basta mudar o registrador de tabela de páginas de modo que aponte para a tabela de páginas de P2 (em vez da de P1); com uma TLB, precisamos limpar as entradas de TLB que pertencem a P1 — tanto para proteger os dados de P1 quanto para forçar a TLB a carregar as entradas para P2. Se a taxa de troca de processos fosse alta, isso poderia ser bastante ineficiente. Por exemplo, P2 poderia carregar apenas algumas entradas de TLB antes que o sistema operacional trocasse novamente para P1. Infelizmente, P1, então, descobriria que todas as suas entradas de TLB desapareceram e seria penalizado com falhas de TLB para recarregá-las. Esse problema ocorre porque os endereços virtuais usados por P1 e P2 são iguais e precisamos limpar a TLB a fim de evitar confundir esses endereços.
troca de contexto Uma mudança no estado interno do processador para permitir que um
processo diferente use o processador, o que inclui salvar o estado necessário e retornar ao processo sendo atualmente executado. Uma alternativa comum é estender o espaço de endereçamento virtual acrescentando um identificador de processo ou identificador de tarefa. O Intrinsity FastMATH possui um campo ID do espaço de endereçamento (ASID) de 8 bits para essa finalidade. Esse pequeno campo identifica o processo que está atualmente sendo executado; ele é mantido em um registrador carregado pelo sistema operacional quando muda de processo. O identificador de processo é concatenado com a parte da tag da TLB, de modo que um acerto de TLB ocorra apenas se o número de página e o identificador de processo corresponderem. Essa combinação elimina a necessidade de limpar a TLB, exceto em raras ocasiões. Problemas semelhantes podem ocorrer para uma cache, já que, em uma troca de processo, a cache conterá dados do processo em execução. Esses problemas surgem de diferentes maneiras para caches física e virtualmente endereçadas; além disso, diversas soluções diferentes, como os identificadores de processo, são usadas para garantir que um processo obtenha seus próprios dados.
Tratando falhas de TLB e faltas de página Embora a tradução de endereços físicos para virtuais com uma TLB seja simples quando temos um acerto de TLB, como já vimos, o tratamento de falhas de TLB e de faltas de página é mais complexo. Uma falha de TLB ocorre quando nenhuma entrada na TLB corresponde a um endereço virtual. Lembre-se de que uma falha de TLB pode indicar uma de duas possibilidades: 1. A página está presente na memória e precisamos apenas criar a entrada de TLB ausente. 2. A página não está presente na memória e precisamos transferir o controle para o sistema operacional a fim de lidar com uma falta de página. O MIPS tradicionalmente trata uma falha de TLB por software. Ele traz a entrada da tabela de páginas da memória e, depois, executa novamente a instrução que causou a falha de TLB. Na reexecução, ele terá um acerto de TLB. Se a entrada da tabela de páginas indicar que a página não está na memória, dessa vez ele terá uma exceção de falta de página. Tratar uma falha de TLB ou uma falta de página requer o uso do mecanismo
de exceção para interromper o processo ativo, transferir o controle ao sistema operacional e, depois, retomar a execução do processo interrompido. Uma falta de página será reconhecida em algum momento durante o ciclo de clock usado para acessar a memória. A fim de reiniciar a instrução após a falta de página ser tratada, o contador de programa da instrução que causou a falta de página precisa ser salvo. Assim como no Capítulo 4, o contador de programa de exceção (EPC) é usado para conter esse valor. Além disso, uma falha de TLB ou uma exceção de falta de página precisa ser sinalizada no final do mesmo ciclo de clock em que ocorre o acesso à memória, de modo que o próximo ciclo de clock começará o processamento da exceção, em vez de continuar a execução normal das instruções. Se a falta de página não fosse reconhecida nesse ciclo de clock, uma instrução load poderia substituir um registrador, e isso poderia ser desastroso quando tentássemos reiniciar a instrução. Por exemplo, considere a instrução lw $1,0($1): o computador precisa ser capaz de impedir que o estágio de escrita do resultado do pipeline ocorra; caso contrário, ele não poderia reiniciar corretamente a instrução, já que o conteúdo de $1 teria sido destruído. Uma complicação parecida surge nos stores. Precisamos impedir que a escrita na memória realmente seja concluída quando há uma falta de página; isso normalmente é feito desativando a linha de controle de escrita para a memória.
Interface hardware/software Entre o momento em que começamos a executar o tratamento de exceção no sistema operacional e o momento em que o sistema operacional salvou todo o estado do processo, o sistema operacional se torna particularmente vulnerável. Por exemplo, se outra exceção ocorresse quando estivéssemos processando a primeira exceção no sistema operacional, a unidade de controle substituiria o contador de programa de exceção, tornando impossível voltar para a instrução que causou a falta de página! Podemos evitar este desastre fornecendo a capacidade de desabilitar e habilitar exceções. Assim que uma exceção ocorre, o processador liga um bit que desabilita todas as outras exceções; isso poderia acontecer ao mesmo tempo em que o processador liga o bit de modo supervisor. O sistema operacional, então, salva o estado apenas suficiente para lhe permitir se recuperar se outra exceção ocorrer — a saber, os registradores do contador de programa de exceção (EPC) e Cause. EPC e Cause são dois dos registradores de controle especiais que ajudam com exceções, falhas de
TLB e faltas de página; a Figura 5.33 mostra o restante. O sistema operacional, então, pode habilitar novamente as exceções. Essas etapas asseguram que as exceções não façam com que o processador perca qualquer estado e, portanto, sejam incapazes de reiniciar a execução da instrução interruptora.
FIGURA 5.33 Registradores de controle MIPS. Considera-se que estes estejam no coprocessador 0, e por isso são lidos com mfc0 e escritos com mtc0.
habilitar exceção Também chamado de “habilitar interrupção”. Uma ação ou sinal que controla se o processo responde ou não a uma exceção; necessário para evitar a ocorrência de exceções durante intervalos antes que o processador tenha seguramente salvado o estado necessário para a reinicialização. Uma vez que o sistema operacional conhece o endereço virtual que causou a falta de página, ele precisa completar três etapas: 1. Consultar a entrada de tabela de páginas usando o endereço virtual e encontrar o local em disco da página referenciada. 2. Escolher uma página física a ser substituída; se a página escolhida estiver com o bit de modificação ligado, ela precisará ser escrita no disco antes que possamos definir uma nova página virtual para essa página física. 3. Iniciar uma leitura de modo a trazer a página referenciada do disco para a página física escolhida. É claro que essa última etapa levará milhões de ciclos de clock de processador
(assim como a segunda, se a página substituída estiver com o bit de modificação ligado); portanto, o sistema operacional normalmente selecionará outro processo para executar no processador até que o acesso ao disco seja concluído. Como o sistema operacional salvou o estado do processo, ele pode passar o controle do processador à vontade para outro processo. Quando a leitura da página do disco está completa, o sistema operacional pode restaurar o estado do processo que causou originalmente a falta de página e executar a instrução que retorna da exceção. Essa instrução passará o processador do modo kernel para o modo usuário, bem como restaurará o contador de programa. O processo do usuário, então, reexecuta a instrução que causou a falta de página, acessa a página requisitada com sucesso e continua a execução. As exceções de falta de página para acessos a dados são difíceis de implementar corretamente em um processador, devido a uma combinação de três fatores: 1. Elas ocorrem no meio das instruções, diferente das faltas de página de instruções. 2. A instrução não pode ser completada, antes que a exceção seja tratada. 3. Após tratar a exceção, a instrução precisa ser reinicializada como se nada tivesse ocorrido. Tornar instruções reinicializáveis, de modo que a exceção possa ser tratada e a instrução possa ser continuada, é relativamente fácil em uma arquitetura como o MIPS. Como cada instrução escreve apenas um item de dados e essa escrita ocorre no final do ciclo da instrução, podemos simplesmente impedir que a instrução seja concluída (não escrevendo) e reinicializar a instrução no começo.
instrução reinicializável Uma instrução que pode retomar a execução, após uma exceção ser resolvida, sem que a exceção afete o resultado da instrução. Vejamos o MIPS mais de perto. Quando uma falha de TLB ocorre, o hardware do MIPS salva o número de página da referência em um registrador especial chamado BadVAddr e gera uma exceção. A exceção chama o sistema operacional, que trata a falha por software. O controle é transferido para o endereço 8000 0000hexa (o local do handler da falha de TLB). A fim de encontrar o endereço físico para a página ausente, a rotina de
falha de TLB indexa a tabela de páginas usando o número de página do endereço virtual e o registrador de tabela de páginas, que indica o endereço inicial da tabela de páginas do processo ativo. Para tornar essa indexação rápida, o hardware do MIPS coloca tudo que você precisa no registrador especial Context: os 12 bits mais significativos têm o endereço da base da tabela de páginas e os próximos 18 bits têm o endereço virtual da página ausente. Como cada entrada de tabela de páginas possui uma palavra, os últimos dois bits são 0. Portanto, as duas primeiras instruções copiam o registrador Context para o registrador temporário do kernel $k1 e, depois, carregam a entrada de tabela de páginas desse endereço em $k1. Lembre-se de que $k0 e $k1 são reservados para uso do sistema operacional sem salvamento; um motivo importante para essa convenção é tornar rápido o handler de falha de TLB. A seguir está o código MIPS para um handler de falha de TLB típico:
handler Nome de uma rotina de software chamada para “tratar” uma exceção ou interrupção.
Como mostrado anteriormente, o MIPS possui um conjunto especial de instruções de sistema que atualiza a TLB. A instrução tlbwr copia o registrador de controle EntryLo para a entrada de TLB selecionada pelo registrador de controle Random. Random implementa uma substituição aleatória e, portanto, é basicamente um contador de execução livre. Uma falha de TLB leva cerca de 12 ciclos de clock. Observe que o handler de falha de TLB não verifica se a entrada de tabela de páginas é válida. Como a exceção para a entrada de TLB ausente é muito mais frequente do que uma falta de página, o sistema operacional carrega a TLB da tabela de páginas sem examinar a entrada e reinicializa a instrução. Se a entrada for inválida, ocorre outra exceção diferente, e o sistema operacional reconhece a
falta de página. Esse método torna rápido o caso frequente de uma falha de TLB, com uma pequena penalidade de desempenho para o raro caso de uma falta de página. Uma vez que o processo que gerou a falta de página tenha sido interrompido, ele transfere o controle para 8000 0180hexa, um endereço diferente do handler de falha de TLB. Esse é o endereço geral para exceção; a falha de TLB possui um ponto de entrada especial que reduz a penalidade para uma falha de TLB. O sistema operacional usa o registrador Cause de exceção a fim de diagnosticar a causa da exceção. Como a exceção é uma falta de página, o sistema operacional sabe que será necessário um processamento extenso. Portanto, diferente de uma falha de TLB, ele salva todo o estado do processo ativo. Esse estado inclui todos os registradores de uso geral e de ponto flutuante, o registrador de endereço de tabela de páginas, o EPC e o registrador Cause de exceção. Como os handlers de exceção normalmente não usam os registradores de ponto flutuante, o ponto de entrada geral não os salva, deixando isso para os poucos handlers que precisam deles. A Figura 5.34 esboça o código MIPS de um handler de exceção. Note que salvamos e restauramos o estado no código MIPS, tomando cuidado quando habilitamos e desabilitamos exceções, mas chamamos o código C para tratar da exceção em particular.
FIGURA 5.34 Código MIPS para salvar e restaurar o estado em uma exceção.
O endereço virtual que causou a falta de página depende dessa exceção ter sido uma falta de instruções ou de dados. O endereço da instrução que gerou a falta está no EPC. Se ela fosse uma falta de página de instruções, o EPC manteria o endereço virtual da página que gerou a falta; caso contrário, o endereço virtual que gerou a falta pode ser calculado examinando a instrução (cujo endereço está no EPC) para encontrar o registrador base e o campo offset.
Detalhamento
Essa versão simplificada considera que o stack pointer (sp) é válido. Para evitar o problema de uma falta de página durante esse código de exceção de baixo nível, o MIPS separa uma parte do seu espaço de endereçamento que não pode ter faltas de página, chamada não mapeada (unmapped). O sistema operacional insere o código para o ponto de entrada do tratamento de exceções e a pilha de exceção na memória não mapeada. O hardware MIPS traduz os endereços virtuais de 8000 0000hexa a BFFF FFFFhexa para endereços físicos simplesmente ignorando os bits superiores do endereço virtual, colocando, assim, esses endereços na parte inferior da memória física. Portanto, o sistema operacional coloca os pontos de entrada dos tratamentos de exceções e as pilhas de exceção na memória não mapeada.
não mapeada Uma parte do espaço de endereçamento que não pode ter faltas de página.
Detalhamento O código na Figura 5.34 mostra a sequência de retorno da exceção do MIPS32. A arquitetura MIPS-I mais antiga usa rfe e jr em vez de eret.
Detalhamento Para processadores com instruções mais complexas, que podem alcançar muitos locais de memória e escrever muitos itens de dados, tornar as instruções reiniciáveis é muito mais difícil. Processar uma instrução pode gerar uma série de faltas de página no meio da instrução. Por exemplo, os processadores x86 possuem instruções de movimento em bloco que alcançam milhares de palavras de dados. Nesses processadores, as instruções normalmente não podem ser reiniciadas desde o início, como fazemos para instruções MIPS. Em vez disso, a instrução precisa ser interrompida e mais tarde continuada no meio de sua execução. Retomar uma instrução no meio de sua execução normalmente exige salvar algum estado especial, processar a exceção e restaurar esse estado especial. Para que isso seja feito corretamente, é preciso haver uma coordenação cuidadosa e detalhada entre o código de tratamento de exceção no sistema operacional e o hardware.
Detalhamento
Detalhamento Em vez de pagar um nível extra de indireção em cada acesso à memória, o VMM mantém uma tabela de página sombra que mapeia diretamente o espaço de endereçamento virtual do guest ao espaço de endereços físicos do hardware. Detectando todas as modificações na tabela de página do guest, o VMM pode garantir que as entradas da tabela de páginas sombra, usadas pelo hardware para as traduções, correspondam àquelas do ambiente de OS do guest, com a exceção das páginas físicas corretas substituídas pelas páginas reais nas tabelas do guest. Logo, o VMM precisa interceptar qualquer tentativa pelo OS guest de mudar sua tabela de páginas ou acessar o ponteiro da tabela de páginas. Isso normalmente é feito protegendo-se a escrita das tabelas de páginas do guest e interceptando qualquer acesso ao ponteiro da tabela de páginas por um OS guest. Como já observamos, essa última ação acontece naturalmente quando o acesso ao ponteiro da tabela de páginas é uma operação privilegiada.
Detalhamento A parte final da arquitetura a virtualizar é a E/S. Esta é, de longe, a parte mais difícil da virtualização do sistema, devido ao crescente número de dispositivos de E/S conectados ao computador e ao aumento da diversidade de tipos de dispositivo de E/S. Outra dificuldade é o compartilhamento de um dispositivo real entre diversas VMs, e ainda outra vem do suporte aos milhares de drivers de dispositivo que são exigidos, especialmente se diferentes OSs guest forem admitidos no mesmo sistema de VM. A ilusão da VM pode ser mantida dando-se a cada VM versões genéricas de cada tipo de driver de dispositivo de E/S, e depois, deixando que o VMM cuide da E/S real.
Detalhamento Além de virtualizar o conjunto de instruções para uma máquina virtual, outro desafio é a virtualização da memória virtual, pois cada OS guest em cada máquina virtual gerencia seu próprio conjunto de tabelas de página. Para que isso funcione, o VMM separa as noções de memória real e memória física (que geralmente são tratadas como sinônimas), e torna a memória real um nível intermediário, separado, entre a memória virtual e a memória física. Alguns usam os termos memória virtual, memória física e memória de
máquina para indicar os mesmos três níveis. O OS guest mapeia a memória virtual à memória real por meio de suas tabelas de páginas, e as tabelas de páginas do VMM mapeiam a memória real do guest à memória física. A arquitetura da memória virtual é especificada ou por meio de tabelas de páginas, como no IBM VM/370 e no x86, ou por meio da estrutura de TLB, como no MIPS.
Resumo Memória virtual é o nome para o nível da hierarquia de memória que controla a caching entre a memória principal e a memória secundária. A memória virtual permite que um único programa expanda seu espaço de endereçamento para além dos limites da memória principal. Mais importante, a memória virtual suporta o compartilhamento da memória principal entre vários processos simultaneamente ativos, de uma maneira protegida. Gerenciar a hierarquia de memória entre a memória principal e o disco é uma tarefa difícil devido ao alto custo das faltas de página. Várias técnicas são usadas para reduzir a taxa de falhas: 1. As páginas são ampliadas para tirar proveito da localidade espacial e para reduzir a taxa de falhas. 2. O mapeamento entre endereços virtuais e endereços físicos, que é implementado com uma tabela de páginas, é feito totalmente associativo para que uma página virtual possa ser colocada em qualquer lugar na memória principal. 3. O sistema operacional usa técnicas, como LRU e um bit de referência, para escolher que páginas substituir. Como as gravações no disco são dispendiosas, a memória virtual usa um esquema write-back e também monitora se uma página foi modificada (usando um bit de modificação) para evitar gravar páginas não alteradas novamente no disco. O mecanismo de memória virtual fornece tradução de endereços de um endereço virtual usado pelo programa para o espaço de endereçamento físico usado no acesso à memória. Esta tradução de endereços permite compartilhamento protegido da memória principal e oferece várias vantagens adicionais, como a simplificação da alocação de memória. Para garantir que os processos sejam protegidos uns dos outros, é necessário que apenas o sistema operacional possa mudar as traduções de endereços, o que é implementado
impedindo que programas de usuário alterem as tabelas de páginas. O compartilhamento controlado das páginas entre processos pode ser implementado com a ajuda do sistema operacional e dos bits de acesso na tabela de páginas, que indicam se o programa do usuário possui acesso de leitura ou escrita a uma página. Se um processador precisasse acessar uma tabela de páginas residente na memória para traduzir cada acesso, a memória virtual seria muito dispendiosa e as caches não teriam sentido! Em vez disso, uma TLB age como uma cache para traduções da tabela de páginas. Os endereços são, então, traduzidos do virtual para o físico usando as traduções na TLB. As caches, a memória virtual e as TLBs se baseiam em um conjunto comum de princípios e políticas. A próxima seção aborda essa estrutura comum.
Entendendo o desempenho dos programas Embora a memória virtual tenha sido criada para permitir que uma memória pequena aja como uma grande, a diferença de desempenho entre o disco e a memória significa que se um programa acessa rotineiramente mais memória virtual do que a memória física que possui, sua execução será muito lenta. Esse programa estaria continuamente trocando páginas entre a memória e o disco, o que chamamos de thrashing. O thrashing, embora raro, é um desastre quando ocorre. Se seu programa realiza thrashing, a solução mais fácil é executá-lo em um computador com mais memória ou comprar mais memória para o computador. Uma opção mais complexa é reexaminar suas estruturas de dados e algoritmo para ver se você pode mudar a localidade e, portanto, reduzir o número de páginas que seu programa usa simultaneamente. Este conjunto de páginas é informalmente chamado de working set. Um problema de desempenho mais comum são as falhas de TLB. Como uma TLB pode tratar apenas de 32 a 64 entradas de página ao mesmo tempo, um programa poderia facilmente ver uma alta taxa de falhas de TLB, já que o processador pode acessar menos de um quarto de megabyte diretamente: 64 × 4 KiB = 0,25 MiB. Por exemplo, as falhas de TLB normalmente são um problema para o Radix Sort. A fim de tentar amenizar esse problema, a maioria das arquiteturas de computadores, agora suporta tamanhos de página variáveis. Por exemplo, além da página de 4 KiB padrão, o hardware do MIPS suporta páginas de 16 KiB, 64 KiB, 256 KiB, 1 KiB, 4 KiB, 16 KiB, 64 MiB e 256 MiB. Consequentemente, se um programa usa grandes tamanhos de
página, ele pode acessar mais memória diretamente sem falhas de TLB. Na prática, o problema é fazer o sistema operacional permitir que os programas selecionem esses tamanhos de página maiores. Mais uma vez, a solução mais complexa para reduzir as falhas de TLB é reexaminar as estruturas de dados e os algoritmos no sentido de reduzir o working set de páginas; dada a importância dos acessos à memória para o desempenho e a frequência de falhas de TLB, alguns programas com grandes working sets foram recriados com esse objetivo.
Verifique você mesmo Associe os termos à esquerda com as definições correspondentes na coluna da direita. 1. Cache L1
a. Uma cache para uma cache.
2. Cache L2
b. Uma cache para discos.
3. Memória principal c. Uma cache para uma memória principal. 4. TLB
d. Uma cache para entradas de tabela de páginas.
5.8. Uma estrutura comum para hierarquias de memória Agora você reconhece que os diferentes tipos de hierarquias de memória compartilham muita coisa. Embora muitos aspectos das hierarquias de memória difiram quantitativamente, muitas das políticas e recursos que determinam como uma hierarquia funciona são semelhantes em qualidade. A Figura 5.35 mostra como algumas características quantitativas das hierarquias de memória podem diferir. No restante desta seção, discutiremos os aspectos operacionais comuns das hierarquias de memória e como determinar seu comportamento. Examinaremos essas políticas como uma série de questões que se aplicam entre quaisquer dos níveis de uma hierarquia de memória, embora usemos principalmente terminologia de caches por motivo de simplicidade.
FIGURA 5.35 Os principais parâmetros quantitativos do projeto que caracterizam os principais elementos da hierarquia de memória em um computador. Estes são valores típicos para esses níveis em 2012. Embora o intervalo de valores seja grande, isso ocorre parcialmente porque muitos dos valores que mudaram com o tempo estão relacionados; por exemplo, à medida que as caches se tornam maiores para contornar maiores penalidades de falha, os tamanhos de bloco também crescem. Embora não seja mostrado, os microprocessadores de servidor de hoje possuem caches L3, que podem ter de 2 a 8 MiB e conter muito mais blocos do que as caches L2. Caches L3 reduzem a penalidade por falta de cache L2 para 30 a 40 ciclos de clock.
Questão 1: onde um bloco pode ser colocado? Vimos que o posicionamento de bloco no nível superior da hierarquia pode utilizar diversos esquemas, do diretamente mapeado ao associativo por conjunto e ao totalmente associativo. Como já dissemos, toda essa faixa de esquemas pode ser imaginada como variações em um esquema associativo por conjunto, no qual o número de conjuntos e o número de blocos por conjunto variam: Nome do esquema
Número de conjuntos
Blocos por conjunto
Mapeamento Direto
Número de blocos na cache
1
Associativo por conjunto
Totalmente associativo
Associatividade (normalmente 2 a 16)
1
Número de blocos na cache
A vantagem de aumentar o grau de associatividade é que normalmente isso diminui a taxa de falhas. A melhoria da taxa de falhas deriva da redução das falhas que disputam o mesmo local. Examinaremos essas falhas mais detalhadamente em breve. Antes, vejamos quanta melhoria é obtida. A Figura 5.36 mostra as taxas de falhas para diversos tamanhos de cache enquanto a associatividade varia de mapeamento direto para a associatividade por conjunto
de oito vias, o que produz uma redução de 20% a 30% na taxa de falhas. Conforme crescem os tamanhos de cache, a melhoria relativa da associatividade aumenta apenas ligeiramente; como a perda geral de uma cache maior é menor, a oportunidade de melhorar a taxa de falhas diminui e a melhoria absoluta na taxa de falhas da associatividade é reduzida significativamente. As possíveis desvantagens da associatividade, como já mencionado, são o custo mais alto e o tempo de acesso mais longo.
FIGURA 5.36 As taxas de falhas da cache de dados para cada um dos oito tamanhos melhora à medida que a associatividade aumenta. Embora a vantagem em passar de associação por conjunto de uma via (mapeamento direto) para duas vias seja significativo, os benefícios de maior associatividade são menores (por exemplo, 1%-10% de melhoria passando de duas vias para quatro vias contra 20%-30% de melhoria passando de uma via para duas vias). Há ainda menos melhoria ao passar de quatro vias para oito vias, que, por sua vez, é muito próximo das taxas de falhas de uma cache totalmente associativa. As caches menores obtêm um benefício absoluto muito maior com a associatividade, pois a
taxa de falhas básica de uma cache pequena é maior. A Figura 5.16 explica como esses dados foram coletados.
Questão 2: como um bloco é encontrado? A escolha de como localizamos um bloco depende do esquema de posicionamento do bloco, já que isso determina o número de locais possíveis. Poderíamos resumir os esquemas da seguinte maneira: Associatividade
Método de localização
Comparações necessárias
Mapeamento Direto
Indexação
1
Associativo por conjunto Indexação do conjunto, pesquisa entre os elementos Total
Grau de associatividade
Pesquisa de todas as entradas de cache
Tamanho da cache
Tabela de consulta separada
0
A escolha entre os métodos mapeamento direto, associativo por conjunto ou totalmente associativo em qualquer hierarquia de memória dependerá do custo de uma falha comparado com o custo de implementar a associatividade, ambos em termos de tempo e de hardware extra. Incluir a cache L2 no chip permite uma associatividade muito mais alta, pois os tempos de acerto não são tão importantes, e o projetista não precisa se basear nos chips SRAM padrão como blocos de construção. As caches totalmente associativas são proibitivas exceto para pequenos tamanhos, nos quais o custo dos comparadores não é grande e as melhorias da taxa de falhas absoluta são as maiores. Nos sistemas de memória virtual, uma tabela de mapeamento separada (a tabela de páginas) é mantida para indexar a memória. Além do armazenamento necessário para a tabela, usar um índice exige um acesso extra à memória. A escolha da associatividade total para o posicionamento de página e da tabela extra é motivada pelos seguintes fatos: 1. A associatividade total é benéfica, já que as falhas são muito dispendiosas. 2. A associatividade total permite que softwares usem esquemas sofisticados de substituição projetados para reduzir a taxa de falhas. 3. O mapa completo pode ser facilmente indexado sem a necessidade de pesquisa e de qualquer hardware extra. Portanto, os sistemas de memória virtual quase sempre usam posicionamento totalmente associativo. O posicionamento associativo por conjunto é muitas vezes usado para caches
e TLBs, no qual o acesso combina indexação e a pesquisa de um conjunto pequeno. Alguns sistemas têm usado caches com mapeamento direto devido às suas vantagens no tempo de acesso e da simplicidade. A vantagem no tempo de acesso ocorre porque a localização do bloco requisitado não depende de uma comparação. Essas escolhas de projeto dependem de muitos detalhes da implementação, como: se a cache é on-chip, a tecnologia usada para implementar a cache e o papel vital do tempo de acesso na determinação do tempo de ciclo do processador.
Questão 3: que bloco deve ser substituído em uma falha de cache? Quando uma falha ocorre em uma cache associativa, precisamos decidir qual bloco substituir. Em uma cache totalmente associativa, todos os blocos são candidatos à substituição. Se a cache for associativa por conjunto, precisamos escolher entre os blocos do conjunto. É claro que a substituição é fácil em uma cache diretamente mapeada, porque existe apenas um candidato. Existem duas principais estratégias para substituição nas caches associativas por conjunto ou totalmente associativas: ▪ Substituição aleatória: os blocos candidatos são selecionados aleatoriamente, talvez usando alguma assistência do hardware. Por exemplo, o MIPS suporta substituição aleatória para falhas de TLB. ▪ Substituição LRU (Least Recently Used): o bloco substituído é o que não foi usado há mais tempo. Na prática, o LRU é muito oneroso de ser implementado para hierarquias com mais do que um pequeno grau de associatividade (geralmente, de dois a quatro), já que é oneroso controlar o uso das informações. Mesmo para a associatividade por conjunto de quatro vias, o LRU normalmente é aproximado — por exemplo, monitorando qual par de blocos é o LRU (o que requer 1 bit) e, depois, monitorando que bloco em cada par é o LRU (o que requer 1 bit por par). Para maior associatividade, o LRU é aproximado ou a substituição aleatória é usada. Nas caches, o algoritmo de substituição está no hardware, o que significa que o esquema deve ser fácil de implementar. A substituição aleatória é simples de construir em hardware e, para uma cache associativa por conjunto de duas vias, a substituição aleatória possui uma taxa de falhas cerca de 1,1 vez mais alta do que a substituição LRU. Conforme as caches se tornam maiores, a taxa de falhas para as duas estratégias de substituição cai e a diferença absoluta se torna
pequena. Na verdade, a substituição aleatória, algumas vezes, pode ser melhor do que as aproximações simples de LRU que são facilmente implementadas em hardware. Na memória virtual, alguma forma de LRU é sempre aproximada, já que mesmo uma pequena redução na taxa de falhas pode ser importante quando o custo de uma falha é enorme. Os bits de referência ou funcionalidade equivalente costumam ser fornecidos para facilitar que o sistema operacional monitore um conjunto de páginas usadas menos recentemente. Como as falhas são muito caras e relativamente raras, é aceitável aproximar essa informação, especialmente, em nível de software.
Questão 4: o que acontece em uma escrita? Uma importante característica de qualquer hierarquia de memória é como ela lida com as escritas. Já vimos as duas opções básicas: ▪ Write-through: as informações são escritas no bloco da cache e no bloco do nível inferior da hierarquia de memória (memória principal para uma cache). As caches na Seção 5.3 usaram esse esquema. ▪ Write-back: as informações são escritas apenas no bloco da cache. O bloco modificado é escrito no nível inferior da hierarquia apenas quando ele é substituído. Os sistemas de memória virtual sempre usam write-back, pelas razões explicadas na Seção 5.7. Tanto write-back quanto write-through têm suas vantagens. As principais vantagens do write-back são as seguintes: ▪ As palavras individuais podem ser escritas pelo processador na velocidade em que a cache, não a memória, pode aceitar. ▪ Diversas escritas dentro de um bloco exigem apenas uma escrita no nível inferior da hierarquia. ▪ Quando blocos são escritos com write-back, o sistema pode fazer uso efetivo de uma transferência de alta largura de banda, já que o bloco inteiro é escrito. O write-through possui estas vantagens: ▪ As falhas são mais simples e baratas porque nunca exigem que um bloco seja escrito de volta no nível inferior. ▪ O write-through é mais fácil de ser implementado do que o write-back, embora, para ser prática, uma cache write-through precisaria usar um buffer de escrita.
Colocando em perspectiva Embora as caches, as TLBs e a memória virtual inicialmente possam parecer muito diferentes, elas se baseiam nos mesmos dois princípios de localidade e podem ser entendidos examinando como lidam com quatro questões: Ques Onde um bloco pode ser colocado? t ã o 1 : Resp Em um local (mapeamento direto), em alguns locais (associatividade por conjunto) ou em qualquer local o (associatividade total). st a : Ques Como um bloco é encontrado? t ã o 2 : Resp Existem quatro métodos: indexação (como em uma cache diretamente mapeada), pesquisa limitada (como em o uma cache associativa por conjunto), pesquisa completa (como em uma cache totalmente associativa) e st tabela de consulta separada (como em uma tabela de páginas). a : Ques Que bloco é substituído em uma falha? t ã o 3 : Resp Em geral, o bloco usado menos recentemente ou um bloco aleatório. o st a : Ques Como as escritas são tratadas? t ã o 4 : Resp Cada nível na hierarquia pode usar write-through ou write-back. o st a :
Em sistemas de memória virtual, apenas uma política write-back é viável devido à longa latência de uma escrita no nível inferior da hierarquia. A taxa em que as escritas são geradas por um processador excederá a taxa em que o sistema de memória pode processá-las, até mesmo permitindo memórias física e logicamente mais largas, e modos burst para a DRAM. Como consequência, hoje em dia as caches de nível mais baixo geralmente usam uma estratégia writeback.
Os três Cs: um modelo intuitivo para entender o comportamento das hierarquias de memória Nesta subseção, vamos examinar um modelo que esclarece as origens das falhas em uma hierarquia de memória e como as falhas serão afetadas por mudanças na hierarquia. Explicaremos as ideias em termos de caches, embora elas se apliquem diretamente a qualquer outro nível na hierarquia. Neste modelo, todas as falhas são classificadas em uma de três categorias (os três Cs):
modelo dos três Cs Um modelo de cache em que todas as falhas são classificadas em uma de três categorias: falhas compulsórias, falhas de capacidade e falhas de conflito. ▪ Falhas compulsórias: são falhas de cache causadas pelo primeiro acesso a um bloco que nunca esteve na cache. Também são chamadas de falhas de partida a frio. ▪ Falhas de capacidade: são falhas de cache causadas quando a cache não pode conter todos os blocos necessários durante a execução de um programa. As falhas de capacidade ocorrem quando os blocos são substituídos e, depois, recuperados. ▪ Falhas de conflito: são falhas de cache que ocorrem em caches associativas por conjunto ou diretamente mapeadas quando vários blocos disputam o mesmo conjunto. As falhas de conflito são aquelas falhas em uma cache diretamente mapeada ou associativa por conjunto que são eliminadas em uma cache totalmente associativa do mesmo tamanho. Essas falhas de cache também são chamadas de falhas de colisão.
falha compulsória
Também chamada de falha de partida a frio. Uma falha de cache causada pelo primeiro acesso a um bloco que nunca esteve na cache.
falha de capacidade Uma falha de cache que ocorre porque a cache, mesmo com associatividade total, não pode conter todos os blocos necessários para satisfazer à requisição.
falha de conflito Também chamada de falha de colisão. Uma falha de cache que ocorre em uma cache associativa por conjunto ou diretamente mapeada quando vários blocos competem pelo mesmo conjunto e que são eliminados em uma cache totalmente associativa do mesmo tamanho. A Figura 5.37 mostra como a taxa de falhas se divide nas três origens. Essas origens de falhas podem ser diretamente atacadas mudando algum aspecto do projeto da cache. Como as falhas de conflito surgem diretamente da disputa pelo mesmo bloco de cache, aumentar a associatividade reduz as falhas de conflito. Entretanto, a associatividade pode aumentar o tempo de acesso, levando a um menor desempenho geral.
FIGURA 5.37 A taxa de falhas pode ser dividida em três origens de falha. Este gráfico mostra a taxa de falhas total e seus componentes para uma faixa de tamanhos de cache. Esses dados são para os benchmarks de inteiro e ponto flutuante do SPEC CPU2000 e são da mesma fonte dos dados na Figura 5.36. O componente da falha compulsória é de 0,006% e não pode ser visto nesse gráfico. O próximo componente é a taxa de falhas de capacidade, que depende do tamanho da cache. A parte do conflito, que depende da associatividade e do tamanho da cache, é mostrada para uma faixa de associatividades, de uma via a oito vias. Em cada caso, a seção rotulada corresponde ao aumento na taxa de falhas que ocorre quando a associatividade é alterada do próximo grau mais alto para o grau de associatividade rotulado. Por exemplo, a seção rotulada como duas vias indica as falhas adicionais surgindo quando a cache possui associatividade de dois em vez de quatro. Portanto, a diferença na taxa de falhas incorrida por uma cache diretamente mapeada em relação a uma cache totalmente associativa do mesmo tamanho é dada pela soma das seções rotuladas como quatro vias, duas vias e uma via. A diferença entre oito vias e quatro vias é tão pequena que mal pode ser vista nesse gráfico.
As falhas de capacidade podem facilmente ser reduzidas aumentando a cache; na verdade, as caches de segundo nível têm se tornado constantemente maiores durante muitos anos. É claro que, quando tornamos a cache maior, também precisamos ser cautelosos quanto ao aumento no tempo de acesso, que pode levar a um desempenho geral mais baixo. Por isso, as caches de primeiro nível cresceram lentamente ou nem isso. Como as falhas compulsórias são geradas pela primeira referência a um bloco, a principal maneira de um sistema de cache reduzir o número de falhas compulsórias é aumentando o tamanho do bloco. Isso irá reduzir o número de referências necessárias para tocar cada bloco do programa uma vez, porque o programa consistirá em menos blocos de cache. Como já dissemos, aumentar demais o tamanho do bloco pode ter um efeito negativo sobre o desempenho devido ao aumento na penalidade de falha. A decomposição das falhas nos três Cs é um modelo qualitativo útil. Nos projetos de cache reais, muitas das escolhas de projeto interagem, e mudar uma característica de cache frequentemente afetará vários componentes da taxa de falhas. Apesar dessas deficiências, esse modelo é uma maneira útil de adquirir conhecimento sobre o desempenho dos projetos de cache.
Colocando em perspectiva A dificuldade de projetar hierarquias de memória é que toda mudança que melhore potencialmente a taxa de falhas, também pode afetar negativamente o desempenho geral, como mostra a Figura 5.38. Essa combinação de efeitos positivos e negativos é o que torna o projeto de uma hierarquia de memória interessante.
FIGURA 5.38 Dificuldades do projeto de hierarquias de memória.
Verifique você mesmo Quais das seguintes afirmativas (se houver) normalmente são verdadeiras? 1. Não há um meio de reduzir as falhas compulsórias. 2. As caches totalmente associativas não possuem falhas de conflito. 3. Na redução de falhas, a associatividade é mais importante do que a capacidade.
5.9. Usando uma máquina de estado finito para controlar uma cache simples Agora, podemos implementar o controle para uma cache, assim como implementamos o controle para os caminhos de dados de único ciclo e em pipeline, no Capítulo 4. Esta seção começa com uma definição de uma cache simples e depois uma descrição das máquinas de estado finito (MEF). Ela termina com a MEF de um controlador para essa cache simples.
Uma cache simples Vamos projetar um controlador para uma cache simples. Aqui estão as principais características da cache: ▪ Cache mapeada diretamente. ▪ Write-back usando alocação de escrita. ▪ O tamanho do bloco é de 4 palavras (16 bytes ou 128 bits). ▪ O tamanho da cache é de 16 KiB, de modo que ela mantém 1024 blocos. ▪ Endereços de 32 bits. ▪ A cache inclui um bit de validade e um bit de modificação por bloco. Pela Seção 5.3, podemos agora calcular os campos de um endereço para a cache: ▪ O índice da cache tem 10 bits. ▪ O offset do bloco tem 4 bits. ▪ O tamanho da tag tem 32 – (10 + 4) ou 18 bits. Os sinais entre o processador e a cache são: ▪ 1 bit de sinal Read ou Write. ▪ 1 bit de sinal Valid, dizendo se existe uma operação de cache ou não. ▪ 32 bits de endereço.
▪ 32 bits de dados do processador à cache. ▪ 32 bits de dados da cache ao processador. ▪ 1 bit de sinal Ready, dizendo que a operação da cache está completa. A interface entre a memória e a cache tem os mesmos campos que entre o processador e a cache, exceto que os campos de dados agora têm 128 bits de largura. A largura de memória extra geralmente é encontrada nos microprocessadores de hoje, que lida com palavras de 32 bits ou 64 bits no processador, enquanto o controlador da DRAM normalmente tem 128 bits. Fazer com que o bloco de cache combine com a largura da DRAM simplificou o projeto. Aqui estão os sinais: ▪ 1 bit de sinal Read ou Write. ▪ 1 bit de sinal Valid, dizendo se existe uma operação de memória ou não. ▪ 32 bits de endereço. ▪ 128 bits de dados da cache à memória. ▪ 128 bits de dados da memória à cache. ▪ 1 bit de sinal Ready, dizendo que a operação de memória está completa. Observe que a interface para a memória não é um número fixo de ciclos. Consideramos um controlador de memória que notificará a cache por meio do sinal Ready quando a leitura ou escrita na memória terminar. Antes de descrever o controlador de cache, precisamos revisar as máquinas de estados finitos, que nos permitem controlar uma operação que pode utilizar múltiplos ciclos de clock.
Máquinas de estados finitos A fim de projetar a unidade de controle para o caminho de dados de único ciclo, usamos um conjunto de tabelas verdade que especificava a configuração dos sinais de controle com base na classe de instrução. Para uma cache, o controle é mais complexo porque a operação pode ser uma série de etapas. O controle para uma cache precisa especificar os sinais a serem definidos em qualquer etapa e a próxima etapa na sequência.
máquina de estados finitos Uma função lógica sequencial consistindo em um conjunto de entradas e saídas, uma função de próximo estado que mapeia o estado atual e as entradas para um novo estado, e uma função de saída que mapeia o estado atual e, possivelmente, as entradas para um conjunto de saídas ativas.
O método de controle multietapas mais comum é baseado em máquinas de estados finitos, que normalmente são representadas graficamente. Uma máquina de estado finito consiste em um conjunto de estados e instruções sobre como alterar os estados. As instruções são definidas por uma função de próximo estado, que mapeia o estado atual e as entradas de um novo estado. Quando usamos uma máquina de estado finito para controle, cada estado também especifica um conjunto de saídas que são declaradas quando a máquina está nesse estado. A implementação de uma máquina de estados finitos normalmente considera que todas as saídas que não estão declaradas explicitamente têm as declarações retiradas. De modo semelhante, a operação correta do caminho de dados depende do fato de que um sinal que não é declarado explicitamente tem a declaração retirada, em vez de atuar como um don’t care.
função de próximo estado Uma função combinacional que, dadas as entradas e o estado atual, determina o próximo estado de uma máquina de estados finitos. Os controles multiplexadores são ligeiramente diferentes, pois selecionam uma das entradas, seja ela 0 ou 1. Assim, na máquina de estado finito, sempre especificamos a definição de todos os controles multiplexadores com que nos importamos. Quando implementamos a máquina de estado finito com lógica, a definição de um controle como 0 pode ser o default e, portanto, pode não exigir quaisquer portas lógicas. Um exemplo simples de uma máquina de estados finitos aparece no Apêndice B, e se você não estiver familiarizado com o conceito de uma máquina de estado finito, pode querer examinar o Apêndice B antes de prosseguir. Uma máquina de estados finitos pode ser implementada com um registrador temporário que mantém o estado atual e um bloco de lógica combinatória que determina os sinais do caminho de dados a serem ativados e o próximo estado. A Figura 5.39 mostra como essa implementação poderia se parecer. Na Seção B.3, a lógica de controle combinacional para uma máquina de estados finitos é implementada com uma ROM (Read-Only Memory) e uma PLA (Programmable Logic Array). Veja também no Apêndice B uma descrição desses elementos lógicos.
FIGURA 5.39 Controladores da máquina de estados finitos normalmente são implementados com um bloco de lógica combinacional e um registrador para manter o estado atual. As saídas da lógica combinacional são o número do próximo estado e os sinais de controle a serem ativados para o estado atual. As entradas da lógica combinacional são o estado atual e quaisquer entradas usadas para determinar o próximo estado. Observe que, na máquina de estados finitos utilizada neste capítulo, as saídas dependem apenas do estado atual e não das entradas. A seção Detalhamento explica isso em minúcias.
Detalhamento Observe que este projeto simples é chamado de cache com bloqueio, pois o processador precisa esperar até que a cache tenha concluído a solicitação.
Detalhamento O estilo da máquina de estados finitos neste livro é chamado de máquina de Moore, em homenagem a Edward Moore. Sua característica identificadora é que a saída depende apenas do estado atual. Com uma máquina de Moore, a caixa rotulada como lógica de controle combinacional pode ser dividida em duas partes. Uma parte tem a saída de controle e apenas a entrada de estado, enquanto a outra tem apenas a saída do próximo estado. Um estilo alternativo de máquina é uma máquina de Mealy, em homenagem a George Mealy. A máquina de Mealy permite que a entrada e o estado atual sejam usados para determinar a saída. As máquinas de Moore possuem vantagens de implementação em potencial na velocidade e no tamanho da unidade de controle. As vantagens na velocidade ocorrem porque as saídas de controle, que são necessárias logo no começo no ciclo de clock, não dependem das entradas, mas somente do estado atual. No Apêndice B, quando a implementação dessa máquina de estado finito é levada às portas lógicas, a vantagem do tamanho pode ser vista com clareza. A desvantagem em potencial de uma máquina de Moore é que ela pode exigir estados adicionais. Por exemplo, em situações em que existe uma diferença de um estado entre duas sequências de estados, a máquina de Mealy pode unificar os estados, fazendo com que as saídas dependam das entradas.
MEF para um controlador de cache simples A Figura 5.40 mostra os quatro estados do nosso controlador de cache simples:
FIGURA 5.40 Quatro estados do controlador simples.
▪ Ocioso: Esse estado espera uma solicitação de leitura ou escrita válida do processador, que move a MEF para o estado Comparar Tag. ▪ Comparar Tag: Como o nome sugere, este estado testa se a leitura ou escrita solicitada é um acerto ou uma falha. A parte de índice do endereço seleciona a tag a ser comparada. Se ela for válida e a parte de tag do endereço combinar com a tag, é um acerto. Os dados são lidos da palavra selecionada, se for um load ou são escritos na palavra selecionada, se for um store. O sinal Cache Ready é definido em seguida. Se for uma escrita, o bit de modificação é definido como 1. Observe que um acerto de escrita também define o bit de validade e o campo de tag; embora pareça desnecessário, ele é incluído porque a tag é uma única memória, de modo que, para mudar o bit
de modificação, também precisamos mudar os campos de validade e tag. Se for um acerto e o bloco for válido, a MEF retorna ao estado ocioso. Uma falha primeiro atualiza a tag de cache e depois vai para o estado Write-Back, se o bloco nesse local tiver um valor de bit de modificação igual a 1, ou para o estado Alocar, se for 0. ▪ Write-Back: Esse estado escreve o bloco de 128 bits na memória usando o endereço composto da tag e do índice de cache. Continuamos nesse estado esperando pelo sinal Ready da memória. Quando a escrita na memória termina, a MEF vai para o estado Alocar. ▪ Alocar: O novo bloco é apanhado da memória. Permanecemos nesse estado aguardando pelo sinal Ready da memória. Quando a leitura da memória termina, a MEF vai para o estado Comparar Tag. Embora pudéssemos ter ido para um novo estado para completar a operação em vez de reutilizar o estado Comparar Tag, existe muita sobreposição, incluindo a atualização da palavra apropriada no bloco se o acesso foi uma escrita. Esse modelo simples facilmente poderia ser estendido com mais estados, para tentar melhorar o desempenho. Por exemplo, o estado Comparar Tag realiza a comparação e a leitura ou escrita dos dados de cache em um único ciclo de clock. Normalmente, a comparação e acesso à cache são feitos em estados separados, no sentido de tentar melhorar o tempo do ciclo de clock. Outra otimização seria acrescentar um buffer de escrita de modo que pudéssemos salvar o bloco de modificação e depois ler o novo bloco primeiro, de modo que o processador não tenha de esperar por dois acessos à memória em uma falha de modificação. A cache então escreveria o bloco modificado do buffer de escrita enquanto o processador está operando sobre os dados solicitados.
5.10. Paralelismo e hierarquias de memória: coerência de cache Dado que um multiprocessador multicore significa múltiplos processadores em um único chip, esses processadores provavelmente compartilham um espaço de endereçamento físico comum. O caching de dados compartilhados gera um novo problema, pois a visão da memória mantida por dois processadores diferentes é através de suas caches individuais, que, sem quaisquer precauções adicionais, poderiam acabar vendo dois valores diferentes. A Figura 5.35 ilustra o problema e mostra como dois processadores diferentes podem ter dois valores diferentes para o mesmo local. Essa dificuldade geralmente é referenciada como o problema de coerência de cache. Informalmente, poderíamos dizer que um sistema de memória é coerente se qualquer leitura de um item de dados retornar o valor escrito mais recentemente desse item de dados. Essa definição, embora intuitivamente atraente, é vaga e simples; a realidade é muito mais complexa. Essa definição simples contém dois aspectos diferentes do comportamento do sistema de memória, ambos críticos para escrever programas corretos de memória compartilhada. O primeiro aspecto, chamado de coerência, define que valores podem ser retornados por uma leitura. O segundo aspecto, chamado consistência, determina quando um valor escrito será retornado por uma leitura. Vejamos primeiro a coerência. Um sistema de memória é coerente se: 1. Uma leitura por um processador P para um local X que segue uma escrita por P a X, sem escritas de X por outro processador ocorrendo entre a escrita e a leitura por P, sempre retorna o valor escrito por P. Assim, na Figura 5.41, se a CPU A tivesse de ler X após a etapa de tempo 3, ela deverá ver o valor 1. 2. Uma leitura por um processador ao local X que segue uma escrita por outro processador a X retorna o valor escrito se a leitura e escrita forem suficientemente separadas no tempo e nenhuma outra escrita em X ocorrer entre os dois acessos. Assim, na Figura 5.41, precisamos de um mecanismo de modo que o valor 0 na cache da CPU B seja substituído pelo valor 1, após a CPU A armazenar 1 na memória do endereço X, na etapa de tempo 3. 3. As escritas no mesmo local são serializadas; ou seja, duas escritas no mesmo local por dois processadores quaisquer são vistas na mesma ordem
por todos os processadores. Por exemplo, se a CPU B armazena 2 na memória do endereço X após a etapa de tempo 3, os processadores nunca podem ler o valor no local X como 2 e mais tarde lê-lo como 1.
FIGURA 5.41 O problema de coerência de cache para um único local da memória (X), lido e escrito por dois processadores (A e B). Assumimos inicialmente que nenhuma cache contém a variável e que X tem o valor 0. Também consideramos uma cache writethrough; uma cache write-back acrescenta algumas complicações adicionais, porém semelhantes. Depois que o valor de X foi escrito por A, a cache de A e a memória contêm o novo valor, mas a cache de B não, e se B ler o valor de X, ele receberá 0!
A primeira propriedade simplesmente preserva a ordem do programa — certamente esperamos que essa propriedade seja verdadeira nos processadores de 1 core, por exemplo. A segunda propriedade define a noção do que significa ter uma visão coerente da memória: se um processador pudesse ler continuamente um valor de dados antigo, claramente diríamos que a memória estava incoerente. A necessidade de serialização de escrita é mais sutil, mas igualmente importante. Suponha que não serializássemos as escritas, e o processador P1 escreve no local X seguido por P2 escrevendo no local X. Serializar as escritas garante que cada processador verá a escrita feita por P2 em algum ponto. Se não serializássemos as escritas, pode ser que algum processador veja a escrita de P2 primeiro e depois veja a escrita de P1, mantendo o valor escrito por P1 indefinidamente. O modo mais simples de evitar essas dificuldades é garantir que todas as escritas no mesmo local sejam vistas na mesma ordem; essa propriedade é chamada serialização de escrita.
Esquemas básicos para impor a coerência
Esquemas básicos para impor a coerência Em um multiprocessador coerente com a cache, as caches oferecem migração e replicação de itens de dados compartilhados: ▪ Migração: Um item de dados pode ser movido para uma cache local e usado lá de uma forma transparente. A migração reduz a latência para acessar um item de dados compartilhado que está alocado remotamente e a demanda de largura de banda sobre a memória compartilhada. ▪ Replicação: Quando os dados compartilhados estão sendo simultaneamente lidos, as caches fazem uma cópia do item de dados na cache local. A replicação reduz a latência de acesso e a disputa por um item lido de dados compartilhado. É essencial, para o desempenho no acesso aos dados compartilhados, oferecer suporte a essa migração e replicação, de modo que muitos multiprocessadores introduzem um protocolo de hardware que mantém caches coerentes. Os protocolos para manter coerência a múltiplos processadores são chamados de protocolos de coerência de cache. Acompanhar o estado de qualquer compartilhamento de um bloco de dados é essencial para implementar um protocolo coerente com a cache. O protocolo de coerência de cache mais comum é o snooping. Cada cache que tem uma cópia dos dados de um bloco da memória física também tem uma cópia do status de compartilhamento do bloco, mas nenhum estado centralizado é mantido. As caches são todas acessíveis por algum meio de broadcast (um barramento ou rede), e todos os controladores monitoram ou vasculham o meio, a fim de determinar se eles têm ou não uma cópia de um bloco que é solicitado em um acesso ao barramento ou switch. Na próxima seção, explicamos a coerência de cache baseada em snooping conforme implementada com um barramento compartilhado, mas qualquer meio de comunicação que envia falhas de cache por broadcast a todos os processadores pode ser usado para implementar um esquema de coerência baseado em snooping. Esse broadcasting de todas as caches torna os protocolos de snooping simples de implementar, mas também limita sua escalabilidade.
Protocolos de snooping Um método para impor a coerência é garantir que um processador tenha acesso exclusivo a um item de dados antes de escrevê-lo. Esse estilo de protocolo é chamado protocolo de invalidação de escrita, pois invalida as cópias em outras caches em uma escrita. O acesso exclusivo garante que não existe qualquer outra
cópia de um item passível de leitura ou escrita quando ocorre a escrita: todas as outras cópias do item em cache são invalidadas. A Figura 5.42 mostra um exemplo de um protocolo de invalidação para um barramento de snooping com caches write-back em ação. Para ver como esse protocolo garante a coerência, considere uma escrita seguida por uma leitura por outro processador: como a escrita requer acesso exclusivo, qualquer cópia mantida pelo processador de leitura precisa ser invalidada (daí o nome do protocolo). Sendo assim, quando ocorre a leitura, ela falha na cache, e esta é forçada a buscar uma nova cópia dos dados. Para uma escrita, exigimos que o processador escrevendo tenha acesso exclusivo, impedindo que qualquer outro processador seja capaz de escrever simultaneamente. Se dois processadores tentarem escrever os mesmos dados simultaneamente, um deles vence a corrida, fazendo com que a cópia do outro processador seja invalidada. Para que o outro processador complete sua escrita, ele precisa obter uma nova cópia dos dados, que agora precisa conter o valor atualizado. Portanto, esse protocolo também impõe a serialização da escrita.
FIGURA 5.42 Um exemplo de um protocolo de invalidação atuando sobre um barramento de snooping para um único bloco de cache (X) com caches write-back. Consideramos que nenhuma cache mantém X inicialmente e que o valor de X na memória é 0. O conteúdo da CPU e da memória mostra o valor após o processador e a atividade do barramento terem sido completados. Um espaço em branco indica nenhuma atividade ou nenhuma cópia em cache. Quando ocorre a segunda falha por B, a CPU A responde com o valor cancelando a resposta da memória. Além disso, tanto o conteúdo da cache de B quanto o conteúdo de memória de X são atualizados. Essa atualização de memória, que ocorre quando um bloco se torna compartilhado, simplifica o protocolo, mas é possível
acompanhar a posse e forçar o write-back somente se o bloco for substituído. Isso requer a introdução de um estado adicional, chamado “owner” (proprietário), que indica que um bloco pode ser compartilhado, mas o processador que o possui é responsável por atualizar quaisquer outros processadores e memória quando muda o bloco ou o substitui.
Interface hardware/software Uma ideia interessante é que o tamanho do bloco desempenha um papel importante na coerência da cache. Por exemplo, considere o caso do snooping em uma cache com um tamanho de bloco de oito palavras, com uma única palavra alternativamente escrita e lida por dois processadores. A maioria dos protocolos troca blocos inteiros entre os processadores, aumentando assim as demandas da largura de banda de coerência. Blocos grandes também podem causar o que é chamado compartilhamento falso: quando duas variáveis compartilhadas não relacionadas estão localizadas no mesmo bloco de cache, o bloco inteiro é trocado entre os processadores, embora os processadores estejam acessando variáveis diferentes. Os programadores e compiladores deverão dispor os dados cuidadosamente para evitar o compartilhamento falso.
compartilhamento falso Quando duas variáveis compartilhadas não relacionadas estão localizadas no mesmo bloco de cache e o bloco inteiro é trocado entre os processadores, embora os processadores estejam acessando variáveis diferentes.
Detalhamento Embora as três propriedades listadas no início desta seção sejam suficientes para garantir a coerência, a questão de quando um valor escrito será visto também é importante. Para ver por que, observe que não podemos exigir que uma leitura de X na Figura 5.41 veja instantaneamente o valor escrito para X por algum outro processador. Se, por exemplo, uma escrita de X em um processador preceder uma leitura de X em outro processador pouco antes, pode ser impossível garantir que a leitura retorne o valor dos dados escritos, pois estes podem nem sequer ter saído do processador, nesse ponto. A questão
de exatamente quando um valor escrito deverá ser visto por um leitor é definido por um modelo de consistência de memória. Fazemos as duas suposições a seguir. Primeiro, uma escrita não termina (e permite que ocorra a próxima escrita) até que todos os processadores tenham visto o efeito dessa escrita. Em segundo lugar, o processador não muda a ordem de qualquer escrita com relação a qualquer outro acesso à memória. Essas duas condições significam que, se um processador escreve no local X seguido pelo local Y, qualquer processador que vê o novo valor de Y também deve ver o novo valor de X. Essas restrições permitem que o processador reordene as leituras, mas força o processador a terminar uma escrita na ordem do programa.
Detalhamento Como a entrada pode mudar a memória por trás das caches e como a saída poderia precisar do valor mais recente em uma cache write-back, também há um problema de coerência de cache para E/S com as caches de um único processador, bem como entre as caches de multiprocessadores. O problema da coerência de cache para multiprocessadores e E/S (ver Capítulo 6), embora semelhante em origem, tem diferentes características que afetam a solução apropriada. Diferente da E/S, em que múltiplas cópias de dados são um evento raro — a ser evitado sempre que possível —, um programa sendo executado em múltiplos processadores normalmente terá cópias dos mesmos dados em várias caches.
Detalhamento Além do protocolo de coerência de cache baseado em snooping, em que o status dos blocos compartilhados é distribuído, um protocolo de coerência de cache baseado em diretório mantém o status de compartilhamento de um bloco de memória física em apenas um local, chamado diretório. A coerência baseada em diretório tem um overhead de implementação ligeiramente mais alto que o snooping, mas pode reduzir o tráfego entre as caches e, portanto, se expandir para quantidades maiores de processadores.
5.11. Vida real: as hierarquias de memória ARM
Cortex-A8 e Intel Core i7 Nesta seção, veremos a hierarquia de memória dos mesmos dois microprocessadores descritos no Capítulo 4: o ARM Cortex-A8 e o Intel Core i7. Esta seção é baseada na Seção 2.6 de Arquitetura de Computadores: Uma abordagem quantitativa, 5ª edição. A Figura 5.43 resume os tamanhos de endereço e as TLBs dos dois processadores. Observe que o A8 possui duas TLBs com um espaço de endereçamento virtual de 32 bits e um espaço de endereços físicos de 32 bits. O Core i7 possui três TLBs com um endereço virtual de 48 bits e um endereço físico de 44 bits. Embora os registradores de 64 bits do Core i7 possam manter um endereço virtual maior, não houve necessidade de um espaço tão grande pelo software, e os endereços virtuais de 48 bits encurtam tanto o uso de memória da tabela de páginas quanto o hardware da TLB.
FIGURA 5.43 Tradução de endereços e hardware TLB para o ARM Cortex-A8 e Intel Core i7 920. Os dois processadores fornecem suporte a páginas grandes, que são usadas para coisas como o sistema operacional ou no mapeamento de um buffer de quadro. O esquema de página
grande evita o uso de um grande número de entradas para mapear um único objeto que está sempre presente.
A Figura 5.44 mostra suas caches. Lembre-se de que o A8 tem apenas um processador ou núcleo, enquanto o Core i7 tem quatro. Ambos são organizados com caches de instrução L1 de 32 KiB, associativos em conjunto com quatro vias (por núcleo) com blocos de 64 bytes. O A8 usa o mesmo projeto para cache de dados, enquanto o Core i7 mantém tudo igual, exceto pela associatividade, que aumenta para oito vias. Ambos utilizam uma cache L2 unificada associativa em conjunto com oito vias (por núcleo), com blocos de 64 bytes, embora o A8 varie em tamanho de 128 KiB até 1 MiB, enquanto o Core i7 é fixo em 256 KiB. Como o Core i7 é usado para servidores, ele também oferece uma cache L3 compartilhada por todos os núcleos no chip. Seu tamanho varia, dependendo do número de núcleos. Com quatro núcleos, como neste caso, o tamanho é de 8 MiB.
FIGURA 5.44 Caches do ARM Cortex-A8 e do Intel Core i7 920.
Um desafio significativo enfrentado pelos projetistas de cache é dar suporte a processadores como o A8 e o Core i7, que podem executar mais de uma instrução de memória por ciclo de clock. Uma técnica popular é quebrar a cache em bancos e permitir vários acessos paralelos, independentes, desde que os acessos sejam para bancos diferentes. A técnica é semelhante aos bancos de DRAM intercalados (Seção 5.2).
O Core i7 possui otimizações adicionais que permitem reduzir a penalidade de falha. A primeira delas é o retorno da palavra requisitada primeiro em uma falha. Ele também continua executando instruções que acessam a cache de dados durante uma falha de cache. Os projetistas que estão tentando ocultar a latência da falha de cache normalmente usam essa técnica, chamada cache não bloqueante, quando montam processadores com execução fora de ordem. Eles implementam dois tipos de não bloqueio. Acerto sob falha permite acertos de cache adicionais durante uma falha, enquanto falha sob acerto permite múltiplas falhas de cache pendentes. O objetivo do primeiro deles é ocultar alguma latência de falha com outro trabalho, enquanto o objetivo do segundo é sobrepor a latência de duas falhas diferentes.
cache não bloqueante Uma cache que permite que o processador faça referências a ela enquanto a cache está tratando uma falha anterior. A sobreposição de uma grande fração dos tempos de falha para múltiplas falhas pendentes requer um sistema de memória de alta largura de banda, capaz de tratar múltiplas falhas em paralelo. Em um dispositivo móvel pessoal, a memória pode apenas ser capaz de tirar proveito limitado dessa capacidade, mas grandes servidores e multiprocessadores frequentemente possuem sistemas de memória capazes de tratar mais de uma falha pendente em paralelo. O Core i7 possui um mecanismo de pré-busca para acessos a dados. Ele olha um padrão de falhas de dados e usa essas informações para tentar prever o próximo endereço a fim de começar a buscar os dados antes que a falha ocorra. Essas técnicas geralmente funcionam melhor ao acessar arrays em loops. As sofisticadas hierarquias de memória desses chips e a grande fração dos dies dedicada às caches e às TLBs mostram o significativo esforço de projeto despendido para tentar diminuir a lacuna entre tempos de ciclo de processador e latência de memória.
Desempenho das hierarquias de memória do A8 e do Core i7 A hierarquia de memória do Cortex-A8 foi simulada com uma cache L2 associativa em conjunto com oito vias, usando os benchmarks de inteiros Minnespec. Como dissemos no Capítulo 4, Minnespec é um conjunto de benchmarks que consiste nos benchmarks SPEC2000, mas com diferentes entradas, que reduzem os tempos de execução por várias ordens de grandeza. Embora o uso de entradas menores não mude a mistura de instruções, isso afeta o comportamento da cache. Por exemplo, no mcf, o benchmark de inteiros SPEC200 com uso mais intensivo de memória, o Minnespec tem uma taxa de falhas para uma cache de 32 KiB que é de apenas 65% da taxa de falhas para a versão SPEC2000 completa. Para uma cache de 1 MiB, a diferença é um fator de seis! Por esse motivo, não se pode comparar os benchmarks Minnespec com os benchmarks SPEC2000, muito menos os benchmarks SPEC2006 ainda maiores, usados para o Core i7 na Figura 5.47. Em vez disso, os dados são úteis para que se veja o impacto relativo das falhas de cache L1 e L2 e sobre o CPI geral, que
usamos no Capítulo 4. As taxas de falha de cache de instruções do A8 para esses benchmarks (e também para as versões SPEC2000 completas nas quais o Minnespec é baseado) são muito pequenas, mesmo que apenas para a cache L1: perto de zero para a maioria e abaixo de 1% para todos eles. Essa taxa tão baixa provavelmente é resultante da natureza computacionalmente intensa dos programas SPEC e da cache associativa em conjunto com quatro vias, que elimina a maioria das falhas por conflito. A Figura 5.45 mostra os resultados da cache de dados para o A8, que possui taxas de falha de cache L1 e L2 significativas. A penalidade de falha L1 para um Cortex-A8 a 1 GHz é de 11 ciclos de clock, enquanto a penalidade de falha L2 é considerada como sendo 60 ciclos de clock. Usando essas penalidades de falha, a Figura 5.46 mostra a penalidade de falha média por acesso aos dados.
FIGURA 5.45 Taxas de falha de cache de dados para o ARM Cortex-A8 ao executar o Minnespec, uma pequena versão do SPEC2000. Aplicações com maiores pegadas de memória costumam ter maiores taxas de falha em caches L1 e L2. Observe que a taxa L2 é a taxa de falhas global; ou seja, contando todas as referências, incluindo aquelas que acertam na L1 (veja o Detalhamento da Seção 5.4.). Mcf é conhecido como um “cache
buster”. Observe que essa figura é para os mesmos sistemas e benchmarks da Figura 4.76, no Capítulo 4.
FIGURA 5.46 Penalidade média de acesso à memória em ciclos de clock por referência à memória de dados vindo de caches L1 e L2 para o processador ARM executando o Minnespec. Embora as taxas de falhas para L1 sejam significativamente mais altas, a penalidade de falha L2, que é mais de cinco vezes mais alta, significa que as falhas L2 podem contribuir significativamente.
A Figura 5.47 mostra as taxas de falha para as caches do Core i7 usando os benchmarks SPEC2006. A taxa de falhas da cache de instruções L1 varia de
0,1% a 1,8%, com uma média um pouco acima de 0,4%. Essa taxa está de acordo com outros estudos do comportamento da cache de instruções para os benchmarks SPEC CPU2006, que mostram baixas taxa de falhas da cache de instruções. Com taxas de falha da cache de dados L1 variando de 5% a 10%, e às vezes maiores, a importância das caches L2 e L3 deverá ser óbvia. Como o custo para uma falha da memória é de mais de 100 ciclos, e a taxa média de falha de dados na L2 é 4%, a L3 certamente é crítica. Considerando que cerca de metade das instruções são loads ou stores, sem a L3, as falhas da cache L2 poderiam acrescentar dois ciclos por instrução à CPI! Em comparação, a taxa média de falha de dados da L3 de 1% ainda é significativa, mas quatro vezes menor que a taxa de falhas L2 e seis vezes menor que a taxa de falhas L1.
FIGURA 5.47 As taxas de falhas da cache de dados L1, L2 e L3 para o Intel Core i7 executando os benchmarks SPEC CPU2006 completo para inteiros.
Detalhamento
Como a especulação às vezes pode ser errada (Capítulo 4), existem referências à cache de dados L1 que não correspondem a loads ou stores que eventualmente completam a execução. Os dados na Figura 5.45 são medidos contra todas as solicitações, incluindo algumas que são canceladas. A taxa de falhas, quando medida contra apenas os acessos a dados concluídos, é 1,6 vezes maior (uma média de 9,5% contra 5,9% para as falhas de cache de dados L1).
5.12. Mais rápido: Bloqueio de cache e multiplicação matricial Nosso próximo passo, continuando a saga de melhoria de desempenho do DGEMM, ajustando-o ao hardware subjacente, é acrescentar o bloqueio de cache às otimizações de paralelismo de subword e paralelismo em nível de instrução, dos Capítulos 3 e 4. A Figura 5.48 mostra a versão em bloco do DGEMM da Figura 4.80. As mudanças são as mesmas que foram feitas anteriormente, ao passar do DGEMM não otimizado da Figura 3.21 para o DGEMM em bloco da Figura 5.21, anteriormente neste capítulo. Desta vez, estamos apanhando a versão desdobrada do DGEMM, do Capítulo 4, e chamando-a muitas vezes sobre as submatrizes de A, B e C. De fato, as linhas 28– 34 e as linhas 7–8 da Figura 5.48 são idênticas às linhas 14–20 e as linhas 5–6 da Figura 5.21, com exceção do incremento do loop for na linha 7 pela quantidade desdobrada.
FIGURA 5.48 Versão C otimizada do DGEMM da Figura 4.80 usando o bloqueio de cache. Essas mudanças são as mesmas encontradas na Figura 5.21. A linguagem assembly produzida pelo compilador para a função do_block é quase idêntica à Figura 4.81. Mais uma vez, não existe overhead para chamar o do_block, pois o compilador insere a função em linha.
Ao contrário dos capítulos anteriores, não mostramos o código x86 resultante, pois o código do loop interno é quase idêntico à Figura 4.81, já que o bloqueio não afeta a computação, mas apenas a ordem que ela acessa os dados na
memória. O que muda é a contabilidade das instruções de inteiros para implementar os loops for. Ela se expande de 14 instruções antes do loop interno a 8, após o loop da Figura 4.80 para 40 e 28 instruções, respectivamente, para o código de contabilidade gerado para a Figura 5.48. Apesar disso, as instruções extras executadas são ínfimas em comparação com a melhoria no desempenho da redução das falhas de cache. A Figura 5.49 compara o desempenho não otimizado com o otimizações para o paralelismo de subword, paralelismo em nível de instrução e caches. O bloqueio melhora o desempenho sobre o código AVX desdobrado por fatores de 2 a 2,5 para as maiores matrizes. Quando comparamos o código não otimizado com o código contendo todas as três otimizações, a melhoria no desempenho tem fatores de 8 a 15, com o maior aumento para a maior matriz.
FIGURA 5.49 Desempenho de quatro versões do DGEMM de dimensões de matriz 32×32 a 960×960. O código totalmente otimizado para a maior matriz é quase 15 vezes mais rápido que a versão não otimizada na Figura 3.21, no Capítulo 3.
Detalhamento Como dissemos no Detalhamento da Seção 3.8, esses resultados são para o
modo Turbo desligado. Assim como nos Capítulos 3 e 4, quando o ativamos, melhoramos todos os resultados pelo aumento temporário na taxa de clock, de 3,3/2,6 = 1,27. O modo Turbo funciona particularmente bem neste caso, pois está usando apenas um único núcleo de um chip com oito núcleos. Entretanto, se quisermos velocidade, devemos usar todos os núcleos, o que será visto no Capítulo 6.
5.13. Falácias e armadilhas Como um dos aspectos mais naturalmente quantitativos da arquitetura de um computador, a hierarquia de memória pareceria ser menos vulnerável às falácias e armadilhas. Não só houve muitas falácias divulgadas e armadilhas encontradas, mas algumas levaram a grandes resultados negativos. Começamos com uma armadilha que frequentemente pega estudantes em exercícios e exames. Armadilha: ignorar o comportamento do sistema de memória ao escrever programas ou gerar código em um compilador. Isso poderia facilmente ser escrito como uma falácia: “Os programadores podem ignorar as hierarquias de memória ao escrever código.” A avaliação da ordenação na Figura 5.19 e do bloqueio de cache na Seção 5.12 demonstra que os programadores podem facilmente dobrar o desempenho se levarem em conta o comportamento do sistema de memória no projeto de seus algoritmos. Armadilha: esquecer-se de considerar o endereçamento em bytes ou o tamanho de bloco de cache ao simular uma cache. Quando estamos simulando uma cache (manualmente ou por computador), precisamos levar em conta o efeito de um endereçamento em bytes e blocos multipalavra ao determinar para qual bloco de cache um certo endereço é mapeado. Por exemplo, se tivermos uma cache diretamente mapeada de 32 bytes com um tamanho de bloco de 4 bytes, o endereço em bytes 36 é mapeado no bloco 1 da cache, já que o endereço em bytes 36 é o endereço de bloco 9 e (9 mod 8) = 1. Por outro lado, se o endereço 36 for um endereço em palavras, então, ele é mapeado no bloco (36 mod 8) = 4. O problema deve informar claramente a base do endereço.
De modo semelhante, precisamos considerar o tamanho do bloco. Suponha que tenhamos uma cache com 256 bytes e um tamanho de bloco de 32 bytes. Em que bloco o endereço em bytes 300 se encontra? Se dividirmos o endereço 300 em campos, poderemos ver a resposta:
O endereço em bytes 300 é o endereço de bloco
O número de blocos na cache é
O bloco número 9 cai no bloco de cache número (9 mod 8) = 1. Esse erro pega muitas pessoas, incluindo os autores (nos rascunhos anteriores) e instrutores que esquecem se pretendiam que os endereços estivessem em palavras, bytes ou números de bloco. Lembre-se dessa armadilha ao realizar os exercícios. Armadilha: ter menos associatividade em conjunto para uma cache compartilhada que o número de cores ou threads compartilhando essa cache. Sem cuidados adicionais, um programa paralelo sendo executado em 2n
processadores ou threads pode facilmente alocar estruturas de dados a endereços que seriam mapeados para o mesmo conjunto de uma cache L2 compartilhada. Se a cache for associativa pelo menos em 2n vias, então esses conflitos acidentais ficam ocultos pelo hardware do programa. Se não, os programadores poderiam enfrentar bugs de desempenho aparentemente misteriosos — na realidade, devido a falhas de conflito L2 — ao migrar de, digamos, um projeto de 16 núcleos para 32 cores, se ambos utilizarem caches L2 associativas com 16 vias.
Armadilha: usar tempo médio de acesso à memória para avaliar a hierarquia de memória de um processador com execução fora de ordem. Se um processador é suspenso durante uma falha de cache, você pode calcular separadamente o tempo de stall de memória e o tempo de execução do processador, e, portanto, avaliar a hierarquia de memória de forma independente usando o tempo médio de acesso à memória (Seção 5.4). Se o processador continuar executando instruções e puder até sustentar mais falhas de cache durante uma falha de cache, então, a única avaliação precisa da hierarquia de memória é simular o processador com execução fora de ordem, juntamente com a hierarquia de memória.
Armadilha: estender um espaço de endereçamento acrescentando segmentos sobre um espaço de endereçamento não segmentado. Durante a década de 1970, muitos programas ficaram tão grandes que nem todo o código e dados podiam ser endereçados apenas com um endereço de 16 bits. Os computadores, então, foram revisados para oferecer endereços de 32 bits, quer por meio de um espaço de endereçamento de 32 bits não segmentado (também chamado de espaço de endereçamento plano), quer acrescentando 16 bits de segmento ao endereço de 16 bits existente. Do ponto de vista do marketing, acrescentar segmentos que fossem visíveis ao programador e que forçassem o programador e o compilador a decomporem programas em segmentos podia resolver o problema de endereçamento. Infelizmente, existe problema toda vez que uma linguagem de programação quer um endereço que seja maior do que um segmento, como índices para grandes arrays, ponteiros irrestritos ou parâmetros por referência. Além disso, acrescentar segmentos pode transformar todos os endereços em duas palavras — uma para o número do segmento e outra para o offset do segmento —, causando problemas no uso dos endereços em registradores. Falácia: as taxas de falha de disco na prática correspondem às suas especificações. Dois estudos recentes avaliaram grandes coleções de discos para verificar a relação entre os resultados na prática e as especificações. Um desses estudos foi de quase 100.000 discos que haviam especificado um MTTF de 1.000.000 a 1.500.000 horas, ou AFR de 0,6% a 0,8%. Eles descobriram que AFRs de 2% a 4% eram comuns, normalmente de três a cinco vezes maiores do que as taxas especificadas (Schroeder et al., 2007). Um segundo estudo de mais de 100.000 discos na Google, que tinha especificado um AFR de cerca de 1,5%, descobriu que as taxas de falha de 1,7% para as unidades em seu primeiro ano subiam para 8,6% para as unidades em seu terceiro ano, ou cerca de cinco a seis vezes a taxa especificada (Pinheiro et al., 2007). Falácia: os sistemas operacionais são o melhor lugar para escalonar os acessos ao disco.
Como dissemos na Seção 5.2, interfaces de disco de nível mais alto oferecem endereços de blocos lógicos ao sistema operacional host. Com essa abstração de alto nível, o melhor que um OS pode fazer para tentar ajudar o desempenho é classificar os endereços de blocos lógicos em ordem crescente. Porém, como o disco conhece o mapeamento real entre os endereços lógicos e a geometria física dos setores, trilhas e superfícies, isso pode reduzir as latências rotacional e de busca por meio do reescalonamento. Por exemplo, suponha que a carga de trabalho seja de quatro leituras (Anderson, 2003): Operação LBA inicial Comprimento Leitura
724
8
Leitura
100
16
Leitura
9987
1
Leitura
26
128
O host poderia reordenar as quatro leituras para a ordem de bloco lógico: Operação LBA inicial Comprimento Leitura
26
128
Leitura
100
16
Leitura
724
8
Leitura
9987
1
Dependendo da localização relativa dos dados no disco, a reordenação poderia tornar isso pior, como demonstra a Figura 5.50. As leituras escalonadas pelo disco são concluídas em três quartos de uma rotação do disco, mas as leituras escalonadas pelo OS exigem três rotações.
FIGURA 5.50 Exemplo mostrando os acessos escalonados pelo OS e pelo disco, rotulados como Fila ordenada pelo host e Fila ordenada pela unidade. O primeiro acesso requer três rotações para concluir as quatro leituras, enquanto o segundo as completa em apenas três quartos de uma rotação (Anderson, 2003).
Armadilha: implementar um monitor de máquina virtual em uma arquitetura de conjunto de instruções que não foi projetada para ser virtualizável. Muitos arquitetos nas décadas de 1970 e 1980 não tiveram o cuidado de garantir que todas as instruções lendo ou escrevendo informações relacionadas a informações de recurso de hardware fossem privilegiadas. Essa atitude laissezfaire causa problemas para os VMMs em todas essas arquiteturas, incluindo o x86, que usamos aqui como um exemplo. A Figura 5.51 descreve as 18 instruções que causam problemas para a virtualização (Robin et al., 2000). As duas classes gerais são instruções que: ▪ Leem os registradores de controle no modo usuário, o que revela que o sistema operacional guest está sendo executado em uma máquina virtual (como POPF, mencionada anteriormente). ▪ Verificam a proteção conforme requisitado pela arquitetura segmentada, mas
consideram que o sistema operacional está sendo executado no nível de privilégio mais alto.
FIGURA 5.51 Resumo de 18 instruções x86 que causam problemas para a virtualização (Robin e Irvine, 2000). As cinco primeiras instruções no grupo de cima permitem que um programa no modo usuário leia um registrador de controle, como um registrador da tabela de descritores, sem causar uma interrupção. A instrução de “pop de flags” modifica um registrador de controle com informações sensíveis, mas falha silenciosamente quando está no modo usuário. A verificação de proteção da arquitetura segmentada do x86 é a ruína do grupo inferior, pois cada uma dessas instruções verifica o nível de privilégio implicitamente como parte da execução da instrução ao ler um registrador de controle. A verificação considera que o OS precisa estar no nível de privilégio mais alto, o que não acontece para as VMs guest. Somente “Mover para/de registradores de segmento” (MOVE) tenta modificar o estado de controle, e a verificação de proteção também falha.
Para simplificar as implementações dos VMMs no x86, tanto AMD quanto Intel propuseram extensões à arquitetura de um novo modo. O VT-x da Intel oferece um novo modo de execução para rodar VMs, uma definição projetada do
estado da VM, instruções para trocar de VMs rapidamente e um grande conjunto de parâmetros para selecionar as circunstâncias em que um VMM precisa ser chamado. Ao todo, o VT-x acrescenta 11 novas instruções para o x86. O Pacifica da AMD tem propostas semelhantes. Uma alternativa para modificar o hardware é fazer pequenas modificações no sistema operacional de modo a evitar o uso de partes problemáticas da arquitetura. Essa técnica é chamada de paravirtualização, e o VMM Xen de fonte aberta é um bom exemplo. O VMM Xen oferece um OS guest com uma abstração de máquina virtual que utiliza apenas as partes fáceis de virtualizar o hardware físico do x86, em que o VMM é executado.
5.14. Comentários finais A dificuldade de construir um sistema de memória para fazer frente aos processadores mais rápidos é acentuada pelo fato de que a matéria-prima para a memória principal, DRAMs, ser essencialmente a mesma nos computadores mais rápidos que nos computadores mais lentos e baratos. É o princípio da localidade que nos dá uma chance de superar a longa latência do acesso à memória — e a confiabilidade dessa estratégia é demonstrada em todos os níveis da hierarquia de memória. Embora esses níveis da hierarquia pareçam muito diferentes em termos quantitativos, eles seguem estratégias semelhantes em sua operação e exploram as mesmas propriedades da localidade.
As caches multiníveis possibilitam o uso mais fácil de outras otimizações por dois motivos. Primeiro, os parâmetros de projeto de uma cache de nível inferior são diferentes dos de uma cache de primeiro nível. Por exemplo, como uma cache de nível inferior será muito maior, é possível usar tamanhos de bloco maiores. Segundo, uma cache de nível inferior não está constantemente sendo usada pelo processador, como em uma cache de primeiro nível. Isso nos permite considerar fazer com que, quando estiver ociosa, uma cache de nível inferior realize alguma tarefa que possa ser útil para evitar futuras falhas. Outra direção possível é recorrer à ajuda de software. Controlar eficientemente a hierarquia de memória usando uma variedade de transformações de programa e recursos de hardware é um importante foco dos avanços dos compiladores. Duas ideias diferentes estão sendo exploradas. Uma é reorganizar o programa para melhorar sua localidade espacial e temporal. Esse método focaliza os programas orientados para loops que usam grandes arrays como a principal estrutura de dados; grandes problemas de álgebra linear são um exemplo típico, como o DGEMM. Reestruturando os loops que acessam os arrays, podemos obter uma localidade — e, portanto, um desempenho de cache — substancialmente melhor.
Outra solução é o prefetching. Em prefetching, um bloco de dados é trazido para a cache antes de ser realmente referenciado. Muitos microprocessadores utilizam o prefetching de hardware para tentar prever os acessos, o que pode ser difícil para o software observar.
prefetching Uma técnica em que os blocos de dados necessários no futuro são colocados na cache antecipadamente pelo uso de instruções especiais que especificam o endereço do bloco. Uma terceira técnica utiliza instruções especiais cientes da cache, que otimizam a transferência da memória. Por exemplo, os microprocessadores na Seção 6.9 do Capítulo 6 utilizam uma otimização que não apanha o conteúdo de um bloco da memória em uma falha de escrita, pois o programa irá escrever o bloco inteiro. Essa otimização reduz significativamente o tráfego da memória para um kernel.
Como veremos no Capítulo 6, os sistemas de memória também são um importante tópico de projeto para processadores paralelos. A crescente importância da hierarquia de memória na determinação do desempenho do sistema significa que essa relevante área continuará a ser o foco de projetistas e pesquisadores ainda por vários anos.
5.15. Exercícios 5.1 Neste exercício, veremos as propriedades de localidade de memória do cálculo de matriz. O código a seguir é escrito em C, em que os elementos dentro da mesma linha são armazenados de forma contígua. Suponha que cada palavra seja um inteiro de 32 bits.
5.1.1 [5] Quantos inteiros de 32 bits podem ser armazenados em uma linha de cache de 16 bytes? 5.1.2 [5] Referências a quais variáveis exibem localidade temporal? 5.1.3 [5] Referências a quais variáveis exibem localidade espacial? A localidade é afetada pela ordem de referência e pelo layout dos dados. O mesmo cálculo também pode ser escrito a seguir em Matlab, que difere da linguagem C armazenando elementos da matriz de forma contígua dentro da mesma coluna.
5.1.4 [10] Quantos blocos de cache de 16 bytes são necessários para armazenar todos os elementos de matriz de 32 bits sendo referenciados? 5.1.5 [5] Referências a quais variáveis exibem localidade temporal? 5.1.6 [5] Referências a quais variáveis exibem localidade espacial? 5.2 As caches são importantes para fornecer uma hierarquia de memória de alto desempenho aos processadores. A seguir se encontra uma lista de referências a endereços de memória de 32 bits, dadas como endereços de palavra. 3, 180, 43, 2, 191, 88, 190, 14, 181, 44, 186, 253
5.2.1 [10] Para cada uma dessas referências, identifique o endereço binário, a tag e o índice dado uma cache de mapeamento direto com 16 blocos de uma palavra. Além disso, indique se cada referência é um acerto ou uma falha, supondo que a cache esteja inicialmente vazia. 5.2.2 [10] Para cada uma dessas referências, identifique o endereço binário, a tag e o índice dado uma cache de mapeamento direto com blocos de duas palavras e um tamanho total de oito blocos. Liste também se cada referência é um acerto ou uma falha, supondo que a cache esteja inicialmente vazia. 5.2.3 [20] Você está encarregado de otimizar um projeto de cache para as referências indicadas. Existem três projetos de cache de mapeamento direto possíveis, todos com um total de oito palavras de dados: C1 tem blocos de uma palavra, C2 tem blocos de duas palavras e C3 tem blocos de quatro palavras. Em termos de taxa de falhas, que projeto de cache é o melhor? Se o tempo de stall de falha é de 25 ciclos, e C1 tem um tempo de acesso de 2 ciclos, C2 utiliza 3 ciclos e C3 utiliza 5 ciclos, qual é o melhor
projeto de cache? Existem muitos parâmetros de projeto diferentes que são importantes para o desempenho geral de uma cache. A lista a seguir indica os parâmetros para diferentes projetos de cache com mapeamento direto. Tamanho de dados da cache: 32 KiB Tamanho de bloco da cache: 2 palavras Tempo de acesso da cache: 1 ciclo 5.2.4 [15] Calcule o número total de bits necessários para a cache listada acima, considerando um endereço de 32 bits. Dado esse tamanho total, ache o tamanho total da cache de mapeamento direto mais próxima com blocos de 16 palavras do mesmo tamanho ou maior. Explique por que a segunda cache, apesar de seu tamanho de dados maior, poderia oferecer desempenho mais lento do que a primeira cache. 5.2.5 [20] Gere uma série de solicitações de leitura que possuem uma taxa de falhas em uma cache associativa em conjunto com duas vias de 2 KB inferior à cache listada na tabela. Identifique uma solução possível que faria com que a cache listada na tabela tivesse uma taxa de falhas igual ou inferior à cache de 2 KiB. Discuta as vantagens e desvantagens de uma solução desse tipo. 5.2.6 [15] A fórmula apresentada na Seção 5.3 mostra o método típico para indexar uma cache mapeada diretamente, especificamente, (Endereço do bloco) módulo (Número de blocos na cache). Supondo um endereço de 32 bits e 1024 blocos na cache, considere uma função de indexação diferente, especificamente, (Endereço de bloco[31:27] XOR Endereço de bloco[26:22]). É possível usar isso para indexar uma cache mapeada diretamente? Se for, explique por que e discuta quaisquer mudanças que poderiam ser necessárias na cache. Se não for possível, explique o motivo. 5.3 Para um projeto de cache mapeada diretamente com endereço de 32 bits, os bits de endereço a seguir são usados para acessar a cache. Tag 31-10
Índice Offset 9-5
4-0
5.3.1 [5] Qual é o tamanho do bloco de cache (em palavras)? 5.3.2 [5] Quantas entradas a cache possui? 5.3.3 [5] Qual é a razão entre o total de bits exigido para essa implementação de cache e os bits de armazenamento de dados? Desde que a alimentação foi ligada, as seguintes referências de cache endereçadas por byte são registradas.
endereçadas por byte são registradas. Endereço 0
4
16
132 232 160 1024
30
140 3100 180 2180
5.3.4 [10] Quantos blocos são substituídos? 5.3.5 [10] Qual é a razão de acerto? 5.3.6 [20] Indique o estado final da cache, com cada entrada válida representada como um registro de . 5.4 Lembre-se de que temos duas políticas de escrita e políticas de alocação de escrita; suas combinações podem ser implementadas na cache L1 ou L2. Considere as seguintes escolhas para as caches L1 e L2: L1
L2
Write-through, sem alocação de escrita Write-back, alocação de escrita
5.4.1 [5] Os buffers são empregados entre diferentes níveis de hierarquia da memória para reduzir a latência de acesso. Para essa configuração dada, liste os possíveis buffers necessários entre as caches L1 e L2, bem como entre a cache L2 e a memória. 5.4.2 [20] Descreva o procedimento de tratamento de uma falha de escrita em L1, considerando o componente envolvido e a possibilidade de substituir um bloco modificado. 5.4.3 [20] Para uma configuração de cache exclusiva multinível (um bloco só pode residir em uma das caches L1 e L2), descreva o procedimento de tratamento de uma falha de escrita em L1, considerando o componente envolvido e a possibilidade de substituir um bloco modificado. Considere os seguintes comportamentos do programa e da cache. Leituras de dados por 1000 instruções
Escritas de dados por 1000 instruções
Taxa de perdas da cache de instruções
Taxa de perdas da cache de dados
Tamanho do bloco (byte)
250
100
0,30%
2%
64
5.4.4 [5] Para uma cache write-through, com alocação de escrita, quais são as larguras de banda mínimas de leitura e escrita (medidas em bytes-por-ciclo) necessárias para alcançar um CPI de 2? 5.4.5 [5] Para uma cache write-back, com alocação de escrita, considerando que 30% dos blocos de cache de dados substituídos são modificados, quais são as larguras de banda mínimas de leitura e escrita
necessárias para um CPI de 2? 5.4.6 [5] Quais são as larguras mínimas de banda necessárias para alcançar o desempenho de CPI = 1,5? 5.5 Aplicações de mídia que tocam arquivos de áudio ou vídeo fazem parte de uma classe de carga de trabalho chamada “streaming”; ou seja, elas trazem grandes quantidades de dados, mas não reutilizam grande parte dele. Considere uma carga de trabalho de streaming de vídeo que acessa um conjunto de trabalho de 512 KiB sequencialmente com o fluxo de endereço a seguir: 0, 2, 4, 6, 8, 10, 12, 14, 16,… 5.5.1 [5] Considere um cache com mapeamento direto de 64 KiB com um bloco de 32 bytes. Qual é a taxa de falhas para esse fluxo de endereços? De que modo essa taxa de falhas é sensível ao tamanho da cache ou ao conjunto de trabalho? Como você categorizaria as falhas que essa carga de trabalho está experimentando, com base no modelo 3C? 5.5.2 [5] Recalcule a taxa de falhas quando o tamanho do bloco de cache é de 16 bytes, 64 bytes e 128 bytes. Que tipo de localidade essa carga de trabalho está explorando? 5.5.3 [10] “Prefetching” é uma técnica que aproveita padrões de endereço previsíveis para trazer blocos de cache adicionais quando determinado bloco de cache é acessado. Um exemplo de prefetching é um buffer de fluxo que pré-busca blocos de cache sequencialmente adjacentes em um buffer separado quando determinado bloco de cache é trazido. Se os dados forem encontrados no buffer de prefetch, eles são considerados um acerto e movidos para a cache, e a próximo bloco de cache é pré-buscado. Considere um buffer de stream de duas entradas e suponha que a latência da cache seja tal que um bloco de cache possa ser carregado antes que o cálculo no bloco de cache anterior seja concluído. Qual é a taxa de falhas para esse stream de endereços? O tamanho do bloco de cache (B) pode afetar a taxa de falhas e a latência de falha. Considerando uma máquina de 1 CPI com uma média de 1,35 referências (a instruções e dados) por instrução, ajude a encontrar o tamanho de bloco ideal dadas as seguintes taxas de falha para diversos tamanhos de bloco. 8: 4%
16: 3% 32: 2% 64: 1,5% 128: 1%
5.5.4 [10] Qual é o tamanho de bloco ideal para uma latência de falha
de 20 × B ciclos? 5.5.5 [10] Qual é o tamanho de bloco ideal para uma latência de falha de 24 + B ciclos? 5.5.6 [10] Para uma latência de falha constante, qual é o tamanho de bloco ideal? 5.6 Neste exercício, veremos as diferentes maneiras como a capacidade afeta o desempenho geral. Normalmente, o tempo de acesso da cache é proporcional à capacidade. Suponha que os acessos à memória principal utilizem 70 ns e que os acessos à memória sejam 36% de todas as instruções. A tabela a seguir mostra dados para caches L1 relacionados a cada um dos dois processadores, P1 e P2. Tamanho L1 Taxa de falhas L1 Tempo de acerto L1 P1
2 KiB
8,0%
0,66 ns
P2
4 KiB
6,0%
0,90 ns
5.6.1 [5] Considerando que o tempo de acerto de L1 determina os tempos de ciclo para P1 e P2, quais são suas respectivas taxas de clock? 5.6.2 [5] Qual é o TMAM para cada um de P1 e P2? 5.6.3 [5] Considerando um CPI base de 1,0 sem stalls de memória, qual é o CPI total para cada um de P1 e P2? Que processador é mais rápido? Para os três problemas a seguir, vamos considerar o acréscimo de uma cache L2 para P1, a fim de, possivelmente, compor sua capacidade limitada de cache L1. Use as capacidades e tempos de acerto da cache L1 da tabela anterior ao resolver esses problemas. A taxa de falhas L2 indicada é a sua taxa de falhas local. Tamanho L2 Taxa de falhas L2 Tempo de acerto L2 1 MB
95%
5,62 ns
5.6.4 [10] Qual é o TMAM para P1 com o acréscimo de uma cache L2? O TMAM é melhor ou pior com a cache L2? 5.6.5 [5] Considerando um CPI base de 1,0 sem stalls de memória, qual é o CPI total para P1 com a adição de um cache L2? 5.6.6 [10] Que processador é mais rápido, agora que P1 tem uma cache L2? Se P1 é mais rápido, que taxa de falhas P2 precisaria em sua cache L1 para corresponder ao desempenho de P1? Se P2 é mais rápido, que taxa de falhas P1 precisaria em seu cache L1 para corresponder ao desempenho de
P2? 5.7 Este exercício examina o impacto de diferentes projetos de cache, especificamente comparando caches associativas com as caches mapeadas diretamente, da Seção 5.4. Para estes exercícios, consulte a tabela de streams de endereço mostrada no Exercício 5.2. 5.7.1 [10] Usando as referências do Exercício 5.2, mostre o conteúdo final da cache para uma cache associativa em conjunto com três vias, com blocos de duas palavras e um tamanho total de 24 palavras. Use a substituição LRU. Em cada referência, identifique os bits de índice, os bits de tag, os bits de offset de bloco e se é um acerto ou uma perda. 5.7.2 [10] Usando as referências do Exercício 5.2, mostre o conteúdo final da cache para uma cache totalmente associativa com blocos de uma palavra e um tamanho total de oito palavras. Use a substituição LRU. Para cada referência, identifique os bits de índice, os bits de tag, e se é um acerto ou uma perda. 5.7.3 [15] Usando as referências do Exercício 5.2, qual é a taxa de perdas para uma cache totalmente associativa com blocos de duas palavras e um tamanho total de oito palavras, usando a substituição LRU? Qual é a taxa de perdas usando a substituição MRU (usado mais recentemente)? Finalmente, qual é a melhor taxa de perdas possível para essa cache, dada qualquer política de substituição? O caching multinível é uma técnica importante para contornar a quantidade limitada do espaço que uma cache de primeiro nível pode oferecer enquanto mantém sua velocidade. Considere um processador com os seguintes parâmetros: CPI base, Velocidade do sem processador stalls da memória
1,5
2 GHz
Tempo de acesso à memória principal
Taxa de perdas da cache de 1° nível por instrução
Cache de segundo nível, velocidade mapeada diretamente
Taxa de perda global com cache de 2° nível, mapeada diretamente
Cache de segundo nível, velocidade associativa em conjunto com oito vias
Taxa de perda global com cache de 2° nível, associativo em conjunto com oito vias
100 ns
7%
12 ciclos
3,5%
28 ciclos
1,5%
5.7.4 [10] Calcule o CPI para o processador na tabela usando: 1) apenas uma cache de primeiro nível, 2) uma cache de mapeamento direto de
segundo nível, e 3) uma cache associativa em conjunto com oito vias de segundo nível. Como esses números mudam se o tempo de acesso da memória principal for dobrado? E se for cortado ao meio? 5.7.5 [10] É possível ter uma hierarquia de cache ainda maior que dois níveis. Dado o processador anterior com uma cache de segundo nível mapeada diretamente, um projetista deseja acrescentar uma cache de terceiro nível que leve 50 ciclos para acessar e que reduzirá a taxa de falhas global para 1,3%. Isso ofereceria melhor desempenho? Em geral, quais são as vantagens e desvantagens de acrescentar uma cache de terceiro nível? 5.7.6 [20] Em processadores mais antigos, como o Intel Pentium e o Alpha 21264, o segundo nível de cache era externo (localizado em um chip diferente) ao processador principal e à cache de primeiro nível. Embora isso permitisse grandes caches de segundo nível, a latência para acessar a cache era muito mais alta, e a largura de banda normalmente era menor, pois a cache de segundo nível trabalhava em uma frequência inferior. Suponha que uma cache de segundo nível de 512 KiB fora do chip tenha uma taxa de perdas global de 4%. Se cada 512 KiB adicionais de cache reduzisse as taxas de perdas globais em 0,7% e a cache tivesse um tempo de acesso total de 50 ciclos, que tamanho a cache deveria ter para corresponder ao desempenho da cache de segundo nível mapeada diretamente, listada na tabela? E ao desempenho da cache associativa em conjunto com oito vias? 5.8 Tempo médio entre falhas (MTBF), tempo médio para o reparo (MTTR) e tempo médio para falhas (MTTF) são métricas úteis para avaliar a confiabilidade e a disponibilidade de um recurso de armazenamento. Explore esses conceitos respondendo às perguntas sobre dispositivos com as métricas a seguir. MTTF
MTTR
3 anos
1 dia
5.8.1 [5] Calcule o MTBF para os dispositivos com as métricas da tabela. 5.8.2 [5] Calcule a disponibilidade para os dispositivos com as métricas na tabela. 5.8.3 [5] O que acontece com a disponibilidade quando o MTTR aproxima-se de 0? Essa é uma situação realista? 5.8.4 [5] O que acontece com a disponibilidade quando o MTTR fica muito alto, ou seja, um dispositivo é difícil de ser reparado? Isso implica que
o dispositivo possui baixa disponibilidade? 5.9 Este exercício examina o código de Hamming de correção de erro único e detecção de erro duplo (SEC/DED). 5.9.1 [5] Qual é o número mínimo de bits de paridade exigidos para proteger uma palavra de 128 bits usando o código SEC/DED? 5.9.2 [5] A Seção 5.5 indica que os módulos de memória de servidor moderno (DIMMs) empregam o ECC SEC/DED para proteger cada 64 bits com 8 bits de paridade. Calcule a razão custo/desempenho desse código com o código do Exercício 5.9.1. Neste caso, o custo é o número relativo de bits de paridade necessários, enquanto o desempenho é o número relativo de erros que podem ser corrigidos. Qual é o melhor? 5.9.3 Considere um código SEC que protege palavras de 8 bits com 4 bits de paridade. Se lêssemos o valor 0x375, haveria um erro? Se sim, corrija o erro. 5.10 Para um sistema de alto desempenho, como um índice B-tree para banco de dados, o tamanho de página é determinado principalmente pelo tamanho dos dados e pelo desempenho do disco. Suponha que, na média, uma página de índice B-tree esteja 70% cheia com entradas de tamanho fixo. A utilidade de uma página é sua profundidade de B-tree, calculada como log2(entradas). A tabela a seguir mostra que, para entradas de 16 bytes, um disco com dez anos de uso, uma latência de 10 ms e uma taxa de transferência de 10 MB/s, o tamanho de página ideal é de 16K. Tamanho de página (KiB)
Utilidade da página ou profundidade da B-tree (número de acessos ao disco salvos)
Custo do acesso à página Utilidade/custo de índice (ms)
2
6,49 (ou log2(2048/16 × 0,7))
10,2
0,64
4
7,49
10,4
0,72
8
8,49
10,8
0,79
16
9,49
11,6
0,82
32
10,49
13,2
0,79
64
11,49
16,4
0,70
128
12,49
22,8
0,55
256
13,49
35,6
0,38
5.10.1 [10] Qual é o melhor tamanho de página se as entradas agora tiverem 128 bytes? 5.10.2 [10] Com base no Exercício 5.10.1, qual é o melhor tamanho de página se as páginas estiverem completas até a metade? 5.10.3 [20] Com base no Exercício 5.10.2, qual é o melhor tamanho de
página se for usado um disco moderno com latência de 3 ms e uma taxa de transferência de 100 MB/s? Explique por que os servidores futuros provavelmente terão páginas maiores. Manter páginas “frequentemente utilizadas” (ou “quentes”) na DRAM pode economizar acessos ao disco, mas como determinamos o significado exato de “frequentemente utilizadas” para determinado sistema? Os engenheiros de dados utilizam a razão de custo entre o acesso à DRAM e ao disco para quantificar o patamar de tempo de reuso para as páginas quentes. O custo de um acesso ao disco é $Disco/acessos_por_segundo, enquanto o custo de manter uma página na DRAM é $DRAM_MiB/tamanho_pag. Os custos típicos de DRAM e disco, e os tamanhos típicos de página de banco de dados em diversos pontos no tempo, são listados a seguir: Ano
Custo da DRAM ($/MiB)
Tamanho da página (KiB)
Custo do disco ($/disco)
Taxa de acesso ao disco (acesso/seg)
1987
5000
1
15000
15
1997
15
8
2000
64
2007
0,05
64
80
83
5.10.4 [10] Quais são os patamares do tempo de reutilização para essas três gerações de tecnologia? 5.10.5 [10] Quais são os patamares do tempo de reutilização se continuarmos usando o mesmo tamanho de página de 4K? Qual é a tendência aqui? 5.10.6 [20] Que outros fatores podem ser alterados para continuar usando o mesmo tamanho de página (evitando assim a reescrita de software)? Discuta sua probabilidade com as tendências atuais de tecnologia e custo. 5.11 Conforme descrevemos na Seção 5.7, a memória virtual utiliza uma tabela de página para rastrear o mapeamento entre endereços virtuais e endereços físicos. Este exercício mostra como essa tabela precisa ser atualizada enquanto os endereços são acessados. A tabela a seguir é um stream de endereços virtuais vistos em um sistema. Considere páginas de 4 KiB, uma TLB totalmente associativa com quatro entradas, e substituição LRU verdadeira. Se as páginas tiverem de ser trazidas do disco, incremente o próximo número de página maior. 4669, 2227, 13916, 34587, 48870, 12608, 49225
TLB
Válido Tag Número da página física 1
11
12
1
7
4
1
3
6
0
4
9
Tabela de página Válido Página física ou no disco 1
5
0
Disco
0
Disco
1
6
1
9
1
11
0
Disco
1
4
0
Disco
0
Disco
1
3
1
12
5.11.1 [10] Dado o stream de endereços na tabela, e o estado inicial mostrado da TLB e da tabela de página, mostre o estado final do sistema. Indique também, para cada referência, se ela é um acerto na TLB, um acerto na tabela de página ou uma falta de página. 5.11.2 [15] Repita o Exercício 5.11.1, mas desta vez use páginas de 16 KiB em vez de páginas de 4 KiB. Quais seriam algumas das vantagens de ter um tamanho de página maior? Quais são algumas das desvantagens? 5.11.3 [15] Mostre o conteúdo final da TLB se ela for associativa em conjunto com duas vias. Mostre também o conteúdo da TLB se ela for mapeada diretamente. Discuta a importância de se ter uma TLB para o desempenho mais alto. Como seriam tratados os acessos à memória virtual se não houvesse TLB? Existem vários parâmetros que afetam o tamanho geral da tabela de página. A seguir estão listados diversos parâmetros importantes da tabela de página. Tamanho do endereço virtual Tamanho da página Tamanho da entrada da tabela de página
Tamanho do endereço virtual Tamanho da página Tamanho da entrada da tabela de página 32 bits
8 KiB
4 bytes
5.11.4 [5] Dados os parâmetros nessa tabela, calcule o tamanho total da tabela de página para um sistema, executando cinco aplicações que utilizam metade da memória disponível. 5.11.5 [10] Dados os parâmetros na tabela anterior, calcule o tamanho total da tabela de página para um sistema executando cinco aplicações que utilizam metade da memória disponível, dada uma técnica de tabela de página de dois níveis com 256 entradas. Suponha que cada entrada da tabela de página principal seja de 6 bytes. Calcule a quantidade mínima e máxima de memória exigida. 5.11.6 [10] Um projetista de cache deseja aumentar o tamanho de uma cache de 4 KiB indexada virtualmente e marcada fisicamente com tags. Dado o tamanho de página listado na tabela anterior, é possível criar uma cache de 16 KiB com mapeamento direto, considerando duas palavras por bloco? Como o projetista aumentaria o tamanho dos dados da cache? 5.12 Neste exercício, examinaremos as otimizações de espaço/tempo para as tabelas de página. A tabela a seguir mostra parâmetros de um sistema de memória virtual. Endereço virtual (bits) DRAM física instalada Tamanho da página Tamanho da PTE (bytes) 43
16 GiB
4 KiB
4
5.12.1 [10] Para uma tabela de página de único nível, quantas entradas da tabela de página (PTE) são necessárias? O quanto de memória física é necessário para armazenar a tabela de página? 5.12.2 [10] O uso de uma tabela de página multinível pode reduzir o consumo de memória física das tabelas de página, apenas mantendo as PTEs ativas na memória física. Quantos níveis de tabelas de página serão necessários nesse caso? E quantas referências de memória são necessárias para a tradução de endereço se estiverem faltando na TLB? 5.12.3 [15] Uma tabela de página invertida pode ser usada para otimizar ainda mais o espaço e o tempo. Quantas PTEs são necessárias para armazenar a tabela de página? Considerando uma implementação de tabela de hash, quais são os números do caso comum e do pior caso das referências à memória necessárias para atender a uma falta de TLB? A tabela a seguir mostra o conteúdo de uma TLB com quatro entradas.
ID entrada Válido Página VA Modificado Proteção Página PA 1
1
140
1
RW
30
2
0
40
0
RX
34
3
1
200
1
RO
32
4
1
280
0
RW
31
5.12.4 [5] Sob que cenários o bit de validade da entrada 2 seria definido como 0? 5.12.5 [5] O que acontece quando uma instrução escreve na página VA 30? Quando uma TLB controlada por software seria mais rápido que uma TLB controlada por hardware? 5.12.6 [5] O que acontece quando uma instrução escreve na página VA 200? 5.13 Neste exercício, examinaremos como as políticas de substituição afetam a taxa de falhas. Considere uma cache associativa em conjunto com duas vias e quatro blocos. Você poderá achar útil desenhar uma tabela para solucionar os problemas neste exercício, conforme demonstramos nesta sequência de endereços: 0, 1, 2, 3, 4. Endereço do bloco de memória acessado
Acerto ou falha
Bloco expulso
Conteúdo dos blocos de cache após referência Conjunto 0
Conjunto 0
Conjunto 1
Conjunto 1
0
Falha
Mem[0]
1
Falha
Mem[0]
2
Falha
Mem[0]
Mem[2]
Mem[1]
3
Falha
Mem[0]
Mem[2]
Mem[1]
Mem[3]
4
Falha
Mem[4]
Mem[2]
Mem[1]
Mem[3]
0
Mem[1]
…
Considere a seguinte sequência de endereços: 0, 2, 4, 8, 10, 12, 14, 16, 0
5.13.1 [5] Considerando uma política de substituição LRU, quantos acertos essa sequência de endereços exibe? 5.13.2 [5] Considerando uma política de substituição MRU (usado mais recentemente), quantos acertos essa sequência de endereços exibe? 5.13.3 [5] Simule uma política de substituição aleatória lançando uma moeda. Por exemplo, “cara” significa expulsar o primeiro bloco em um conjunto e “coroa” significa expulsar o segundo bloco em um conjunto. Quantos acertos essa sequência de endereços exibe?
5.13.4 [10] Que endereço deve ser expulso em cada substituição para maximizar o número de acertos? Quantos acertos essa sequência de endereços exibe se você seguir essa política “ideal”? 5.13.5 [10] Descreva por que é difícil implementar uma política de substituição de cache que seja ideal para todas as sequências de endereço. 5.13.6 [10] Considere que você poderia tomar uma decisão em cada referência de memória se deseja ou não que o endereço requisitado seja mantido em cache. Que impacto poderia ter sobre a taxa de falhas? 5.14 Para dar suporte às máquinas virtuais, dois níveis de virtualização de memória são necessários. Cada máquina virtual ainda controla o mapeamento entre o endereço virtual (VA) e o endereço físico (PA), enquanto o hipervisor mapeia o endereço físico (PA) de cada máquina virtual e o endereço de máquina (MA) real. Para acelerar esses mapeamentos, uma técnica de software chamada “paginação de shadow” duplica as tabelas de página de cada máquina virtual no hipervisor, e intercepta as mudanças de mapeamento entre VA e PA para manter as duas cópias coerentes. A fim de remover a complexidade das tabelas de página de shadow, uma técnica de hardware chamada tabela de página aninhada (ou tabela de página estendida) oferece suporte explícito a duas classes de tabelas de página (VA → PA e PA → MA) e pode percorrer essas tabelas apenas no hardware. Considere esta sequência de operações: (1) Criar processo; (2) Falha de TLB; (3) Falta de página; (4) Troca de contexto; 5.14.1 [10] O que aconteceria à sequência de operação indicada, para a tabela de página de shadow e a tabela de página aninhada, respectivamente? 5.14.2 [10] Considerando uma tabela de página de quatro níveis baseada em x86 tanto na tabela de página guest quanto na aninhada, quantas referências à memória são necessárias para atender a uma falha de TLB para a tabela de página nativa versus aninhada? 5.14.3 [15] Entre a taxa de falhas de TLB, latência de falha de TLB, taxa de falta de página e latência do tratador de falta de página, quais métricas são mais importantes para a tabela de página de shadow? Quais são importantes para a tabela de página aninhada? A tabela a seguir mostra parâmetros para um sistema de página por sombra. Falhas de TLB por 1000 instruções
Latência de falha de TLB NPT
Faltas de página por 1000 instruções
Overhead de shadowing por falta de página
0,2
200 ciclos
0,001
30.000 ciclos
5.14.4 [10] Para um benchmark com CPI de execução nativo de 1, quais são os números de CPI se estiver usando tabelas de página de shadow versus NPT (considerando apenas o overhead de virtualização da tabela de página)? 5.14.5 [10] Que técnicas podem ser usadas para reduzir o overhead induzido pelo shadowing da tabela de página? 5.14.6 [10] Que técnicas podem ser usadas para reduzir o overhead induzido pelo NPT? 5.15 Um dos maiores impedimentos para o uso generalizado das máquinas virtuais é o overhead de desempenho ocasionado pela execução de uma máquina virtual. A tabela a seguir lista diversos parâmetros de desempenho e comportamento de aplicação. CPI base
Acessos privilegiados do O/S por 10.000 instruções
Impacto no desempenho de interceptar o O/S guest
Impacto no desempenho de interceptar a VMM
Acessos de E/S por 10.000 instruções
Tempo de acesso de E/S (inclui tempo para interceptar o O/S guest)
1,5
120
15 ciclos
175 ciclos
30
1100 ciclos
5.15.1 [10] Calcule o CPI para o sistema listado, supondo que não existem acessos à E/S. Qual é o CPI se o impacto do desempenho da VMM dobrar? E se for cortado ao meio? Se uma empresa de software da máquina virtual deseja obter uma degradação de desempenho de 10%, qual é a maior penalidade possível para interceptar a VMM? 5.15.2 [10] Os acessos de E/S normalmente possuem um grande impacto sobre o desempenho geral do sistema. Calcule o CPI de uma máquina usando as características de desempenho anteriores, considerando um sistema não virtualizado. Calcule o CPI novamente, desta vez usando um sistema virtualizado. Como esses CPIs mudam se o sistema tiver metade dos acessos de E/S? Explique por que as aplicações voltadas para E/S possuem um impacto semelhante da virtualização. 5.15.3 [30] Compare as ideias da memória virtual e das máquinas virtuais. Como os objetivos de cada um se comparam? Quais são os prós e contras de cada um? Liste alguns casos em que a memória virtual é desejada, e alguns casos em que as máquinas virtuais são desejadas. 5.15.4 [20] A Seção 5.6 discute a virtualização sob a hipótese de que o sistema virtualizado esteja executando a mesma ISA do hardware subjacente. Porém, um uso possível da virtualização é simular ISAs não nativas. Um
exemplo disso é QEMU, que simula uma série de ISAs, como o MIPS, SPARC e PowerPC. Quais são algumas das dificuldades envolvidas nesse tipo de virtualização? É possível que um sistema simulado rode mais rápido do que em sua ISA nativa? 5.16 Neste exercício, exploraremos a unidade de controle de um controlador de cache para um processador com um buffer de escrita. Use a máquina de estados finitos encontrada na Figura 5.40 como ponto de partida para projetar suas próprias máquinas de estados finitos. Suponha que o controlador de cache seja para a cache de mapeamento direto descrita na Seção 5.9, mas você acrescentará um buffer de escrita com uma capacidade de um bloco. Lembre-se de que a finalidade de um buffer de escrita é servir como armazenamento temporário, de modo que o processador não precisa esperar por dois acessos à memória em uma falha modificada. Em vez de escrever de volta o bloco modificado antes de ler o novo bloco, ele coloca o bloco modificado no buffer e começa imediatamente a ler o novo bloco. O bloco modificado pode então ser escrito na memória principal enquanto o processador está trabalhando. 5.16.1 [10] O que deve acontecer se o processador emitir uma solicitação que acerta na cache enquanto um bloco está sendo escrito de volta na memória principal a partir do buffer de escrita? 5.16.2 [10] O que deve acontecer se o processador emitir uma solicitação que falha na cache enquanto um bloco está sendo escrito de volta à memória principal a partir do buffer de escrita? 5.16.3 [30] Crie uma máquina de estado finito para permitir o uso de um buffer de escrita. 5.17 A coerência da cache refere-se às visões de múltiplos processadores em determinado bloco de cache. A tabela a seguir mostra dois processadores e suas operações de leitura/escrita em duas palavras diferentes de um bloco de cache X (inicialmente, X[0] = X[1] = 0). Considere que o tamanho dos inteiros seja de 32 bits. P1 X[0] ++; X[1] = 3;
P2 X[0] = 5; X[1] +=2;
5.17.1 [15] Liste os valores possíveis do bloco de cache indicado para uma implementação correta do protocolo de coerência de cache. Liste pelo menos um valor possível do bloco se o protocolo não garantir coerência de cache.
5.17.2 [15] Para um protocolo de snooping, liste uma sequência de operação válida em cada processador/cache para terminar as operações de leitura/escrita listadas anteriormente. 5.17.3 [10] Quais são os números, no melhor caso e no pior caso das falhas de cache, necessários para terminar as instruções de leitura/escrita listadas? A coerência da memória refere-se às visões de múltiplos itens de dados. A tabela a seguir mostra dois processadores e suas operações de leitura/escrita em diferentes blocos de cache (A e B inicialmente 0). P1
P2
A = 1; B = 2; A+=2; B++;
C = B; D = A;
5.17.4 [15] Liste os valores possíveis de C e D para uma implementação que garante as suposições de consistência no início da Seção 5.10. 5.17.5 [15] Liste pelo menos um par possível de valores para C e D se essas suposições não forem mantidas. 5.17.6 [15] Para diversas combinações de políticas de escrita e políticas de alocação de escrita, quais combinações tornam a implementação do protocolo mais simples? 5.18 Multiprocessadores em um chip (CMPs) possuem diversos cores e suas caches em um único chip. O projeto da cache L2 no chip CMP possui opções interessantes. A tabela a seguir mostra as taxas de falhas e as latências de acerto para dois benchmarks com projetos de cache L2 privada versus compartilhada. Considere falhas de cache L1 uma vez a cada 32 instruções. Privada Compartilhada Falhas por instrução no benchmark A
0,30%
0,12%
Falhas por instrução no benchmark B
0,06%
0,03%
Considere as seguintes latências de acerto: Cache privada Cache compartilhada Memória 5
20
180
5.18.1 [15] Qual projeto de cache é melhor para cada um desses benchmarks? Use dados para apoiar sua conclusão. 5.18.2 [15] A latência da cache compartilhada aumenta com o
tamanho do CMP. Escolha o melhor projeto se a latência da cache compartilhada dobrar. Como a largura de banda fora do chip torna-se o gargalo à medida que o número de cores CMP aumenta, escolha o melhor projeto se a latência da memória fora do chip dobrar. 5.18.3 [10] Discuta os prós e os contras das caches L2 compartilhada versus privada para cargas de trabalho de único thread, multithreaded e multiprogramadas, e reconsidere-as se houver caches L3 no chip. 5.18.4 [15] Considere que ambos os benchmarks têm um CPI base de 1 (cache L2 ideal). Se ter uma cache sem bloqueio melhora o número médio de falhas L2 concorrentes de 1 para 2, quanta melhoria de desempenho isso oferece sobre uma cache L2 compartilhada? Quanta melhoria pode ser obtida sobre a L2 privada? 5.18.5 [10] Supondo que novas gerações de processadores dobrem o número de núcleos a cada 18 meses, para manter o mesmo nível de desempenho por núcleo, quanta largura de banda fora do chip a mais é necessária para um processador lançado em três anos? 5.18.6 [15] Considerando a hierarquia de memória inteira, que tipos de otimizações podem melhorar o número de falhas simultâneas? 5.19 Neste exercício, mostramos a definição de um log de servidor Web e examinamos otimizações de código para melhorar a velocidade de processamento do log. A estrutura de dados para o log é definida da seguinte forma:
Considere a seguinte função de processamento em um log:
5.19.1 [5] Quais campos em uma entrada de log serão acessados para a função de processamento de log indicada? Considerando blocos de cache de 64 bytes e nenhum prefetching, quantas falhas de cache por entrada determinada função, contrai na média? 5.19.2 [10] Como você pode reconhecer a estrutura de dados para melhorar a utilização da cache e a localidade do acesso? Mostre seu código de definição da estrutura. 5.19.3 [10] Dê um exemplo de outra função de processamento de log que preferiria um layout de estrutura de dados diferente. Se ambas as funções são importantes, como você reescreveria o programa para melhorar o desempenho geral? Suplemente a discussão com um trecho de código e dados. Para os problemas a seguir, use os dados de “Cache Performance for SPEC CPU2000 Benchmarks” (www.cs.wisc.edu/multifacet/misc/spec2000cache-data/) para os pares de benchmarks mostrados na tabela a seguir. a.
Mesa / gcc
b.
mcf / swim
5.19.4 [10] Para caches de dados de 64 KiB com associatividades de conjunto variadas, quais são as taxas de falhas desmembradas por tipos de falha (falhas frias, de capacidade e de conflito) para cada benchmark? 5.19.5 [10] Selecione a associatividade de conjunto a ser usada por uma cache de dados L1 de 64 KiB compartilhada por ambos os benchmarks. Se a cache L1 tiver de ser mapeada diretamente, selecione a associatividade de conjunto para a cache L2 de 1 MiB. 5.19.6 [20] Dê um exemplo na tabela de taxa de falhas em que a associatividade de conjunto mais alta aumenta a taxa de falhas. Construa uma configuração de cache e fluxo de referência para demonstrar isso.
Respostas das Seções “Verifique você mesmo” §5.1, página 330: 1 e 4 (3 é falso porque o custo da hierarquia de memória varia por computador, mas em 2013 o custo mais alto normalmente é a DRAM.) §5.3, página 348: 1 e 4: Uma penalidade de falha menor pode levar a blocos menores, pois você não tem tanta latência para amortizar, embora uma largura de banda de memória mais alta normalmente leve a blocos maiores,
largura de banda de memória mais alta normalmente leve a blocos maiores, já que a penalidade de falha é apenas ligeiramente maior. §5.4, página 365: 1. §5.7, página 397: 1-a, 2-c, 3-b, 4-d. §5.8, página 403: 2. (Tanto os tamanhos de bloco maiores quanto o prefetching podem reduzir as falhas compulsórias, de modo que 1 é falso.)
Processadores paralelos do cliente à nuvem “Balanço bastante, com tudo o que tenho. Rebato com força ou perco com força. Gosto de viver ao máximo que eu posso.” Babe Ruth Jogador americano de beisebol
6.1 Introdução 6.2 A dificuldade de criar programas com processamento paralelo 6.3 SISD, MIMD, SIMD, SPMD e vetor 6.4 Multithreading do hardware 6.5 Multicore e outros multiprocessadores de memória compartilhada 6.6 Introdução às unidades de processamento de gráficos 6.7 Clusters, computadores em escala warehouse e outros multiprocessadores de passagem de mensagens 6.8 Introdução às topologias de rede multiprocessador 6.9 Benchmarks de multiprocessador e modelos de desempenho 6.10 Vida real: benchmarking e rooflines do Intel Core i7 e GPU NVIDIA Tesla 6.11 Mais rápido: processadores múltiplos e multiplicação matricial
6.12 Falácias e armadilhas 6.13 Comentários finais 6.14 Exercícios
Organização de multiprocessador ou cluster
6.1. Introdução “Sobre as montanhas da lua, pelo vale das sombras, cavalgue, cavalgue corajosamente”, respondeu a sombra — “Se você procura o Eldorado!” Edgar Allan Poe, “Eldorado”, quarta estrofe, 1849
Há muito tempo, os arquitetos de computadores têm buscado o Eldorado do projeto de computadores: criar computadores poderosos simplesmente conectando muitos computadores menores existentes. Esta visão dourada é a origem dos multiprocessadores. Idealmente, o cliente pede tantos processadores quanto seu orçamento permitir e recebe uma quantidade correspondente de desempenho. Portanto, o software para multiprocessadores precisa ser projetado para trabalhar com um número variável de processadores. Como dissemos no Capítulo 1, a potência tornou-se o fator limitante para centros de dados e microprocessadores. Substituir grandes processadores ineficazes por muitos processadores eficazes e menores pode oferecer melhor desempenho por watt ou
por joule tanto no grande quanto no pequeno, se o software puder utilizá-los com eficiência. Assim, a melhor eficiência de energia se junta ao desempenho escalável no caso para os multiprocessadores.
multiprocessador Um sistema de computador com, pelo menos, dois processadores. Isso é o contrário de um uniprocessador, que tem apenas um, e é cada vez mais difícil de encontrar hoje. Como o software multiprocessador é escalável, alguns projetos podem suportar operar mesmo com a ocorrência de quebras no hardware; ou seja, se um único processador falhar em um multiprocessador com n processadores, o sistema fornece serviço continuado com n – 1 processadores. Portanto, os multiprocessadores também podem melhorar a disponibilidade (Capítulo 5). Alto desempenho pode significar alta vazão para tarefas independentes, chamado paralelismo em nível de tarefa, ou paralelismo em nível de processo. Essas tarefas paralelas são aplicações independentes de única thread, e são um uso importante e comum dos processadores múltiplos. Essa técnica é contrária à execução de uma única tarefa em processadores múltiplos. Usamos o termo programa de processamento paralelo para indicar um único programa que é executado em vários processadores simultaneamente.
paralelismo em nível de tarefa ou paralelismo em nível de processo Utilizar vários processadores executando programas independentes simultaneamente.
programa de processamento paralelo Um único programa que é executado em vários processadores simultaneamente. Há muito tempo existem problemas científicos que precisam de computadores muito mais rápidos, e essa classe de problemas tem sido usada para justificar muitos computadores paralelos novos, no decorrer das últimas décadas. Hoje, alguns desses problemas podem ser tratados de forma simples, usando um cluster composto de microprocessadores abrigados em muitos servidores independentes (Seção 6.7). Além disso, os clusters podem servir a aplicações igualmente exigentes fora das ciências, como mecanismos de busca, servidores Web e bancos de dados.
cluster Um conjunto de computadores conectados por uma rede local (LAN) que funciona como um único e grande multiprocessador.
microprocessador multicore Um microprocessador contendo vários processadores (“núcleos”) em um único circuito integrado. Praticamente todos os microprocessadores hoje nos computadores desktop e servidores são multicore. Como dissemos no Capítulo 1, os multiprocessadores ganharam destaque porque o problema da potência significa que aumentos futuros no desempenho aparentemente virão de mais processadores por chip, em vez de taxas de clock mais altas e CPI melhorado. Como vimos naquele capítulo, eles são chamados microprocessadores multicore e não microprocessadores multiprocessadores, provavelmente para evitar redundância de nomeação. Logo, os processadores normalmente são chamados núcleos (cores) em um chip multicore. Espera-se que o número de núcleos aumente conforme a Lei de Moore. Esses multicores quase sempre são processadores de memória compartilhada (SMPs — Shared Memory Processors), pois normalmente compartilham um único espaço de endereços físico. Veremos os SMPs com mais detalhes na Seção 6.5.
processador de memória compartilhada (SMP) Um processador paralelo com um único espaço de endereços.
O estado da tecnologia hoje significa que os programadores que se preocupam com o desempenho precisam se tornar programadores paralelos, pois programas sequenciais implicam em programas lentos. O grande desafio enfrentado pela indústria é criar hardware e software que facilite a escrita de programas de processamento paralelo, que sejam eficientes no desempenho e potência à medida que o número de núcleos por chip aumenta geometricamente. Essa mudança repentina no projeto do microprocessador apanhou muitos de surpresa, de modo que ainda existe muita confusão sobre a terminologia e o que ela significa. A Figura 6.1 tenta esclarecer os termos serial, paralelo, sequencial e concorrente. As colunas dessa figura representam o software, que é inerentemente sequencial ou concorrente. As linhas da figura representam o hardware, que é serial ou paralelo. Por exemplo, os programadores de compiladores pensam neles como programas sequenciais: as etapas são análise léxica, geração de código, otimização e assim por diante. Ao contrário, os programadores de sistemas operacionais normalmente pensam neles como programas concorrentes: processos em cooperação tratando de eventos de E/S devido as tarefas independentes executando em um computador.
FIGURA 6.1 Categorização e exemplos de hardware/software do ponto de vista da concorrência e do paralelismo.
O motivo desses dois eixos da Figura 6.1 é que o software concorrente pode ser executado no hardware serial, como os sistemas operacionais para o uniprocessador Intel Pentium 4, ou no hardware paralelo, como um OS no mais recente Intel Core i7. O mesmo acontece para o software sequencial. Por exemplo, o programador MatLab escreve uma multiplicação de matriz pensando nela sequencialmente, mas poderia executá-la serialmente no hardware do Pentium 4 ou em paralelo no hardware do Core i7. Você poderia supor que o único desafio da revolução paralela é descobrir como fazer com que o software naturalmente sequencial tenha alto desempenho no hardware paralelo, mas também fazer com que os programas concorrentes tenham alto desempenho nos multiprocessadores, à medida que o número de processadores aumenta. Com essa distinção, no restante deste capítulo usaremos programa de processamento paralelo ou software paralelo para indicar o software sequencial ou concorrente executando em hardware paralelo. A próxima seção descreve por que é difícil criar programas eficientes para processamento paralelo. Antes de prosseguirmos ainda mais até o paralelismo, não se esqueça das nossas incursões iniciais dos capítulos anteriores: ▪ Capítulo 2, Seção 2.11: Paralelismo e instruções: Sincronização ▪ Capítulo 3, Seção 3.6: Paralelismo e aritmética computacional: paralelismo subword ▪ Capítulo 4, Seção 4.10: Paralelismo em nível de instrução ▪ Capítulo 5, Seção 5.10: Paralelismo e hierarquias de memória: coerência de cache
Verifique você mesmo Verdadeiro ou falso: para que se beneficie de um multiprocessador, uma
aplicação precisa ser concorrente.
6.2. A dificuldade de criar programas com processamento paralelo A dificuldade com o paralelismo não está no hardware; é que muito poucos programas de aplicação importantes foram escritos para completar as tarefas mais cedo nos multiprocessadores. É difícil escrever software que usa processadores múltiplos para completar uma tarefa mais rápido, e o problema fica pior à medida que o número de processadores aumenta. Mas por que isso acontece? Por que os programas de processamento paralelo devem ser tão mais difíceis de desenvolver do que os programas sequenciais? A primeira razão é que você precisa obter um bom desempenho e eficiência do programa paralelo em um multiprocessador; caso contrário, você usaria um programa sequencial em um processador, já que a programação sequencial é mais simples. Na verdade, as técnicas de projeto de processadores, como execução superescalar e fora de ordem, tiram vantagem do paralelismo em nível de instrução (Capítulo 4), normalmente sem envolvimento do programador. Tais inovações reduzem a necessidade de reescrever programas para multiprocessadores, já que os programadores poderiam não fazer nada e ainda assim seus programas sequenciais seriam executados mais rapidamente nos novos computadores. Por que é difícil escrever programas de multiprocessador que sejam rápidos, especialmente quando o número de processadores aumenta? No Capítulo 1, usamos a analogia de oito repórteres tentando escrever um único artigo na esperança de realizar o trabalho oito vezes mais rápido. Para ter sucesso, a tarefa precisa ser dividida em oito partes de mesmo tamanho, pois senão alguns repórteres estariam ociosos enquanto esperam que aqueles com partes maiores terminem. Outro obstáculo ao desempenho seria que os repórteres gastariam muito tempo se comunicando entre si, em vez de escrever suas partes do artigo. Para essa analogia e para a programação paralela, os desafios incluem escalonamento, particionamento do trabalho em partes paralelas, balanceamento da carga de modo uniforme entre os trabalhadores, tempo para sincronização e overhead para a comunicação entre as partes. O desafio é ainda maior quando aumenta o número de repórteres para um artigo do jornal e quanto aumenta o número de processadores para a programação paralela.Nossa discussão no Capítulo 1 revela outro obstáculo, conhecido como a Lei de Amdahl. Ela nos
lembra que mesmo as pequenas partes de um programa precisam estar em paralelo para que o programa faça bom proveito dos muitos núcleos.
Desafio do speed-up Exemplo Suponha que você queira alcançar um speed-up 90 vezes mais rápido com 100 processadores. Que fração da computação original pode ser sequencial?
Resposta A Lei de Amdahl (Capítulo 1) diz que:
Podemos reformular a lei de Amdahl em termos de speed-up versus o tempo de execução original:
Essa fórmula normalmente é reescrita considerando-se que o tempo de execução anterior é 1 para alguma unidade de tempo, e o tempo de execução afetado pela melhoria é considerado a fração do tempo de execução original:
Substituindo a meta de speed-up por 90 e a quantidade de melhoria por 100 na fórmula anterior:
Então, simplificando a fórmula e resolvendo para a fração de tempo afetada:
Portanto, para obter um speed-up de 90 com 100 processadores, a porcentagem sequencial só poderá ser 0,1%. Entretanto, existem aplicações com um substancial paralelismo, como veremos em seguida.
Desafio do speed-up: ainda maior Exemplo Suponha que você queira realizar duas somas: uma é a soma de 10 variáveis escalares e outra é uma soma matricial de um par de arrays bidimensionais, com dimensões 10 × 10. Por enquanto, vamos considerar que apenas a soma matricial possa se tornar paralela; logo veremos como tornar as somas escalares paralelas. Que speed-up você obtém com 10 versus 40 processadores? Em seguida, calcule os speed-ups supondo que as matrizes crescem para 20 por 20.
Resposta Se considerarmos que o desempenho é uma função do tempo para uma adição, t, então há 10 adições que não se beneficiam dos processadores paralelos e 100 adições que se beneficiam. Se o tempo para um único processador é 110t, o tempo de execução para 10 processadores é
Então, o speed-up com 10 processadores é 110t/20t = 5,5. O tempo de execução para 40 processadores é
de modo que o speed-up com 40 processadores é 110t/12,5t = 8,8. Assim, para o tamanho deste problema, obtemos cerca de 55% do speed-up em potencial com 10 processadores, mas somente 22% com 40. Veja o que acontece quando aumentamos a matriz. O programa sequencial agora utiliza 10t + 400t = 410t. O tempo de execução para 10 processadores é
de modo que o speed-up com 10 processadores é 410t/50t = 8,2. O tempo de execução para 40 processadores é
de modo que o speed-up com 40 processadores é 410t/20t = 20.5. Assim, para esse grande problema, obtemos cerca de 82% do speed-up em potencial
com 10 processadores e 51% com 40. Esses exemplos mostram que obter um bom speed-up em um multiprocessador enquanto se mantém o tamanho do problema fixo é mais difícil do que conseguir um bom speed-up aumentando o tamanho do problema. Isso nos permite apresentar dois termos que descrevem maneiras de expandir. Expansão forte significa medir o speed-up enquanto se mantém o tamanho do problema fixo. Expansão fraca significa que o tamanho do problema cresce proporcionalmente com o aumento no número de processadores. Vamos supor que o tamanho do problema, M, seja o conjunto de trabalho na memória principal, e que temos P processadores. Então, a memória por processador para a expansão forte é aproximadamente M/P, e para a expansão fraca ela é aproximadamente M.
expansão forte Speed-up alcançado em um multiprocessador sem aumentar o tamanho do problema.
expansão fraca Speed-up alcançado em um multiprocessador enquanto se aumenta o tamanho do problema proporcionalmente ao aumento no número de processadores. Observe que a hierarquia de memória pode interferir com a sabedoria convencional, de que a expansão fraca é mais fácil que a expansão forte. Por exemplo, se um conjunto de dados com expansão fraca não couber mais na cache de último nível de um microprocessador multicore, o desempenho resultante poderia ser muito pior do que o uso da expansão forte.
Dependendo da aplicação, você pode argumentar em favor de qualquer uma dessas técnicas de expansão. Por exemplo, o benchmark de banco de dados débito-crédito TPC-C requer que você aumente o número de contas de cliente para conseguir um maior número de transações por minuto. O argumento é que não faz sentido pensar que determinada base de clientes de repente começará a usar caixas eletrônicos 100 vezes por dia só porque o banco adquiriu um computador mais rápido. Em vez disso, se você for demonstrar um sistema que pode funcionar 100 vezes o número de transações por minuto, deverá fazer uma experiência com 100 vezes a quantidade de clientes. Problemas maiores geralmente precisam de mais dados, o que é um argumento em favor da expansão fraca. Este exemplo final mostra a importância do balanceamento de carga.
Desafio do speed-up: balanceamento de carga Exemplo Para conseguir o speed-up de 20,5 no problema maior, mostrado anteriormente, com 40 processadores, consideramos que a carga foi balanceada perfeitamente. Ou seja, cada um dos 40 processadores teve 2,5% do trabalho a realizar. Em vez disso, mostre o impacto sobre o speed-up se a carga de um processador for maior que todo o restante. Calcule com o dobro da carga (5%) e cinco vezes a carga (12,5%) para o processador com mais
trabalho. Qual é a utilização do restante dos processadores?
Resposta Se um processador tem 5% da carga paralela, então ele precisa realizar 5% × 400 ou 20 adições, e os outros 39 compartilharão as 380 restantes. Como eles estão operando simultaneamente, podemos simplesmente calcular o tempo de execução como um máximo
O speed-up cai de 20,5 para 410t/30t = 14. Os 39 processadores restantes são utilizados em menos da metade do tempo: enquanto aguardam 20t para que o processador com mais trabalho termine, eles só realizam uma computação por 380t/39 = 9,7t. Se um processador tem 12,5% da carga, ele precisa realizar 50 adições. A fórmula é
O speed-up cai ainda mais para 410t/60t = 7. O restante dos processadores é utilizado em menos de 20% do tempo (9t/50t). Esse exemplo demonstra o valor do balanceamento de carga, pois apenas um único processador com o dobro da carga dos outros reduz o speed-up em um terço, e cinco vezes a carga em um processador reduz o speed-up por quase um fator de três. Agora que compreendemos melhor os objetivos e os desafios do processamento paralelo, apresentamos uma sinopse do restante do capítulo. A próxima seção (6.3) descreve um esquema de classificação muito mais antigo do que a Figura 6.1. Além disso, ela descreve dois estilos de arquiteturas do conjunto de instruções, que dão suporte à execução de aplicações sequenciais em hardware paralelo, a saber, SIMD e vetor. A Seção 6.4, depois, descreve o multithreading, um termo frequentemente confundido com multiprocessamento,
em parte porque conta com a concorrência semelhante nos programas. A Seção 6.5 descreve a primeira das duas alternativas de uma característica fundamental do hardware paralelo: se todos os processadores nos sistemas contam com um único espaço de endereços físico ou não. Como já dissemos, as duas versões populares dessas alternativas são chamadas multiprocessadores de memória compartilhada (SMPs) e clusters, e essa seção explica a primeira. A Seção 6.6 descreve um estilo de computador relativamente novo, da comunidade de hardware gráfico, chamado unidade de processamento de gráficos (GPU), que também considera um endereço físico único. A Seção 6.7 descreve os clusters, um exemplo popular de um computador com espaços de endereços físicos. A Seção 6.8 mostra as topologias comuns usadas para conectar muitos processadores, sejam nós de servidores e um cluster ou núcleos em um microprocessador. Em seguida, discutimos a dificuldade de localizar benchmarks paralelos, na Seção 6.9. Essa seção também inclui um modelo de desempenho simples, porém esclarecedor, que ajuda no projeto de aplicações e também de arquiteturas. Usamos esse modelo, além de benchmarks paralelos, na Seção 6.10, a fim de comparar um computador multicore com uma GPU. A Seção 6.11 divulga a última e maior etapa em nossa jornada de aceleração da multiplicação matricial. Para matrizes que não cabem na cache, o processamento paralelo usa 16 cores para melhorar o desempenho por um fator de 14. Fechamos com as falácias e armadilhas e nossas conclusões para o paralelismo. Na próxima seção, apresentamos os acrônimos que você provavelmente já viu para identificar diferentes tipos de computadores paralelos.
Verifique você mesmo Verdadeiro ou falso: a expansão forte não está ligada à lei de Amdahl.
6.3. SISD, MIMD, SIMD, SPMD e vetor Uma categorização do hardware paralelo proposta na década de 1960 ainda está em uso atualmente. Ela foi baseada no número de fluxos de instruções e no número de fluxos de dados. A Figura 6.2 mostra as categorias. Portanto, um processador convencional tem um único fluxo de instruções e um único fluxo de dados, e um multiprocessador convencional possui fluxos de instruções e dados múltiplos. Essas duas categorias são abreviadas como SISD e MIMD, respectivamente.
FIGURA 6.2 Categorização de hardware e exemplos baseados no número de fluxos de instruções e fluxos de dados: SISD, SIMD, MISD e MIMD.
SISD ou Single Instruction stream, Single Data stream. Um processador único.
MIMD ou Multiple Instruction streams, Multiple Data streams. Um multiprocessador. Embora seja possível escrever programas separados que são executados em diferentes processadores em um computador MIMD e ainda trabalharem juntos para um objetivo grandioso e coordenado, os programadores normalmente escrevem um único programa que executa em todos os processadores de um computador MIMD, contando com instruções condicionais quando diferentes processadores deveriam executar diferentes seções de código. Esse estilo é chamado Single Program Multiple Data (SPMD), mas é apenas o modo normal de programar um computador MIMD.
SPMD Single Program, Multiple Data streams. O modelo de programação MIMD convencional, em que um único programa é executado em todos os processadores. O mais próximo que podemos chegar de um processador com múltiplos fluxos de instruções e fluxo de dados único (MISD) poderia ser um “processador de fluxo”, que realizaria uma série de cálculos sobre um único fluxo de dados em um padrão em pipeline: analisar a entrada da rede, decodificar os dados, descompactá-los, procurar uma combinação e assim por diante. O inverso do
MISD faz muito mais sentido. Computadores SIMD operam sobre vetores de dados. Por exemplo, uma única instrução SIMD poderia somar 64 números enviando 64 fluxos de dados a 64 ALUs, para formar 64 somas dentro de um único ciclo de clock. As instruções paralelas de subword, que vimos nas Seções 3.6 e 3.7, são outro exemplo de SIMD; na verdade, a letra do meio do acrônimo SSE da Intel significa SIMD.
SIMD ou Single Instruction stream, Multiple Data streams. A mesma instrução é aplicada a muitos fluxos de dados, assim como em um processador de vetor. As virtudes do SIMD são que todas as unidades de execução paralelas são sincronizadas e todas elas respondem a uma única instrução que emana de um único contador de programa (PC). Do ponto de vista de um programador, isso é próximo do já conhecido SISD. Embora cada unidade esteja executando a mesma instrução, cada unidade de execução tem seus próprios registradores de endereço e, portanto, cada unidade pode ter diferentes endereços de dados. Assim, nos termos da Figura 6.1, uma aplicação sequencial poderia ser compilada para executar em hardware serial organizado como um SISD ou em hardware paralelo que foi organizado como um SIMD. A motivação original por trás do SIMD foi amortizar o custo da unidade de controle por dezenas de unidades de execução. Outra vantagem é o tamanho reduzido da memória do programa — SIMD só precisa de uma cópia do código que está sendo executado simultaneamente, enquanto os MIMDs com passagem de mensagem podem precisar de uma cópia em cada processador, e o MIMD com memória compartilhada precisará de múltiplas caches de instrução. SIMD funciona melhor quando lida com arrays em loops for. Logo, para o paralelismo funcionar no SIMD, é preciso haver muitos dados estruturados de forma idêntica, o que é chamado de paralelismo em nível de dados. SIMD é mais fraco em instruções case ou switch, em que cada unidade de execução precisa realizar uma operação diferente sobre seus dados, dependendo de quais dados ela tenha. As unidades de execução com os dados errados devem ser desativadas, de modo que as unidades com dados corretos possam continuar. Se houver n casos, nessas situações, os processadores SIMD basicamente executam em 1/n do desempenho de pico.
paralelismo em nível de dados Paralelismo obtido realizando-se a mesma operação sobre dados independentes. Os chamados processadores de array que inspiraram a categoria SIMD desapareceram na história, mas duas interpretações do SIMD permanecem ativas hoje.
SIMD no x86: extensões de multimídia Conforme descrito no Capítulo 3, o paralelismo de subword para dados inteiros estreitos foi a inspiração original das instruções MMX (Multimedia Extension) do x86, em 1996. À medida que a Lei de Moore continuava, mais instruções eram acrescentadas, levando primeiro às Streaming SIMD Extensions (SSE) e agora às Advanced Vector Extensions (AVX). AVX tem suporte para a execução simultânea de número de ponto flutuante de 64 bits. A largura da operação e dos registradores é codificada no opcode dessas instruções de multimídia. Enquanto a largura de dados de registradores e operações crescia, o número de opcodes para instruções de multimídia explodia, e agora existem centenas de instruções SSE e AVX (Capítulo 3). Vetor Uma interpretação mais antiga e mais elegante do SIMD é a chamada arquitetura de vetor, que possui uma identidade muito próxima dos computadores projetados por Seymour Cray, a partir da década de 1970. Esta, também é uma grande combinação de problemas com muito paralelismo em nível de dados. Em vez de ter 64 ALUs realizando 64 adições simultaneamente, como os antigos processadores de array, as arquiteturas de vetor colocaram a ALU em pipeline para obter bom desempenho com custo reduzido. A filosofia básica da arquitetura de vetor é coletar elementos de dados da memória, ordenálos em um grande conjunto de registradores, operar sobre eles sequencialmente nos registradores usando unidades de execução em pipeline e depois escrever os resultados de volta para a memória. Um recurso importante das arquiteturas de vetor é um conjunto de registradores de vetor. Assim, uma arquitetura de vetor poderia ter 32 registradores de vetor, cada um com 64 elementos de 64 bits.
Comparando código de vetor com código
convencional Exemplo Suponha que estendamos a arquitetura do conjunto de instruções MIPS com instruções e registradores de vetor. As operações de vetor utilizam os mesmos nomes das operações MIPS, mas com a letra “V” acrescentada. Por exemplo, addv.d soma dois vetores de precisão dupla. As instruções de vetor recolhem como entrada um par de registradores de vetor (addv.d) ou um registrador de vetor e um registrador escalar (addvs.d). No segundo caso, o valor no registrador escalar é usado como a entrada para todas as operações — a operação addvs.d somará o conteúdo de um registrador escalar a cada elemento em um registrador de vetor. Os nomes lv e sv indicam load de vetor e store de vetor, e carregam ou armazenam um vetor inteiro de dados de precisão dupla. Um operando é o registrador de vetor a ser carregado ou armazenado; o outro operando, que é um registrador MIPS de uso geral, é o endereço inicial do vetor na memória. Dada essa descrição curta, mostre o código MIPS convencional versus o código MIPS de vetor para
onde X e Y são vetores de 64 números de ponto flutuante com precisão dupla, inicialmente residentes na memória, e a é uma variável escalar de precisão dupla. (Esse exemplo é o chamado loop DAXPY, que forma o loop interno do benchmark Linpack; DAXPY significa Double precision a × X Plus Y.) Suponha que os endereços iniciais de X e Y estejam em $s0 e $s1, respectivamente.
Resposta Aqui está o código MIPS convencional para o DAXPY:
Aqui está o código MIPS de vetor para o DAXPY:
Existem algumas comparações interessantes entre os dois segmentos de código neste exemplo. A mais impressionante é que o processador de vetor reduz bastante a largura de banda de instrução dinâmica, executando apenas seis instruções contra quase 600 para a arquitetura MIPS tradicional. Essa redução ocorre tanto porque as operações de vetor trabalham sobre 64 elementos quanto porque as instruções de overhead que constituem quase metade do loop no MIPS não estão presentes no código de vetor. Como você poderia esperar, essa redução nas instruções buscadas e executadas economiza energia. Outra diferença importante é a frequência dos hazards de pipeline (Capítulo 4). No código MIPS direto, cada add.d precisa esperar por um mul.d, cada s.d precisa esperar pelo add.d e cada add.d e mul.d precisa esperar pelo l.d. No processador de vetor, cada instrução de vetor só gerará stall para o primeiro elemento em cada vetor, depois os elementos subsequentes fluirão tranquilamente pelo pipeline. Assim, os stalls do pipeline só são necessários uma vez por operação de vetor, ao invés de uma vez por elemento de vetor. Neste exemplo, a frequência de stall do pipeline no MIPS será aproximadamente 64
vezes maior do que na versão de vetor do MIPS. Os stalls do pipeline podem ser reduzidos no MIPS usando desdobramento de loop (Capítulo 4). Porém, a grande diferença na largura de banda de instrução não pode ser reduzida.
Como os elementos de vetor são independentes, eles podem ser operados em paralelo, semelhante ao paralelismo de subword para as instruções AVX. Todos os computadores de vetor modernos possuem unidades funcionais de vetor com múltiplos pipelines paralelos (chamadas pistas de vetor; veja as Figuras 6.2 e 6.3) que podem produzir dois ou mais resultados por ciclo de clock.
FIGURA 6.3 Usando múltiplas unidades funcionais para melhorar o desempenho de uma única instrução de soma vetorial, C = A + B. O processador vetorial (a) à esquerda tem um único pipeline de adição por ciclo. O processador vetorial (b) à direita tem quatro pipelines ou pistas de adição e pode completar quatro adições por ciclo. Os elementos dentro de uma única instrução de soma vetorial são intercalados nas quatro pistas.
Detalhamento O loop no exemplo anterior combinou exatamente com o tamanho do vetor. Quando os loops são mais curtos, as arquiteturas de vetor utilizam um registrador que reduz o tamanho das operações de vetor. Quando os loops são maiores, acrescentamos código de contabilidade para percorrer operações de vetor de tamanho total e tratar do restante. Esse último processo é conhecido como strip mining (ou garimpagem).
Vetor versus escalar As instruções de vetor possuem várias propriedades importantes em comparação com as arquiteturas convencionais de conjunto de instruções, que são chamadas arquiteturas escalares nesse contexto: ▪ Uma única instrução de vetor especifica muito trabalho — isso é equivalente a executar um loop inteiro. A largura de banda de busca e decodificação de instrução necessária é bastante reduzida. ▪ Usando uma instrução de vetor, o compilador ou programador indica que o cálculo de cada resultado no vetor é independente do cálculo de outros resultados no mesmo vetor, de modo que o hardware não tem de verificar hazards de dados dentro de uma instrução de vetor. ▪ Arquiteturas e compiladores de vetor têm a reputação de tornar mais fácil do que os multiprocessadores MIMD para escrever aplicações eficientes quando elas contêm paralelismo em nível de dados. ▪ O hardware só precisa verificar hazards de dados entre duas instruções de vetor uma vez por operando de vetor, e não uma vez para cada elemento dentro dos vetores. A redução de verificações pode economizar energia, além de tempo. ▪ Instruções de vetor que acessam a memória possuem um padrão de acesso conhecido. Se os elementos do vetor forem todos adjacentes, então buscar o vetor de um conjunto de bancos de memória bastante intervalados funciona muito bem. Assim, o custo da latência para a memória principal é visto apenas uma vez para o vetor inteiro, e não uma vez para cada palavra do vetor. ▪ Como um loop inteiro é substituído por uma instrução de vetor cujo comportamento é predeterminado, os hazards de controle que, normalmente surgiriam do desvio do loop, são inexistentes. ▪ A economia na largura de banda de instrução e verificação de hazard mais o uso eficaz da largura de banda da memória dão às arquiteturas de vetor, vantagens em potência e energia contra as arquiteturas escalares. Por esses motivos, as operações de vetor podem se tornar mais rápidas que uma sequência de operações escalares sobre o mesmo número de itens de dados, e os projetistas são motivados a incluir unidades de vetor se o domínio da aplicação puder usá-las com frequência.
Vetor versus extensões de multimídia
Assim como as extensões de multimídia encontradas nas instruções AVX do x86, uma instrução de vetor especifica múltiplas operações. Porém, as extensões de multimídia normalmente especificam algumas poucas operações, enquanto a instrução de vetor especifica dezenas de operações. Diferente das extensões de multimídia, o número de elementos em uma operação de vetor não está no opcode, mas em um registrador separado. Isso significa que diferentes versões da arquitetura de vetor podem ser implementadas com um número diferente de elementos, apenas mudando o conteúdo desse registrador e retendo, portanto, a compatibilidade binária. Ao contrário, um novo grande conjunto de opcodes é acrescentado toda vez que o tamanho do “vetor” muda na arquitetura da extensão de multimídia do x86: MMX, SSE, SSE2, AVX, AVX2, .... Também diferente das extensões de multimídia, as transferências de dados não precisam ser contíguas. Os vetores admitem os acessos “strided”, em que o hardware carrega cada n-ésimo elemento de dados na memória, e acessos indexados, em que o hardware encontra os endereços dos itens a serem carregados em um registrador de vetor. Os acessos indexados também são chamados de gather-scatter, pois os loads indexados ajuntam (gather) elementos da memória principal para elementos de vetor contíguos e os stores indexados espalham (scatter) elementos de vetor pela memória principal. Assim como as extensões de multimídia, as arquiteturas de vetor facilmente capturam a flexibilidade nas larguras de dados, de modo que é fácil fazer uma operação de vetor funcionar em 32 elementos de dados de 64 bits ou em 64 elementos de dados de 32 bits ou em 128 elementos de dados de 16 bits ou em 256 elementos de dados de 8 bits. A semântica paralela de uma instrução de vetor permite que uma implementação execute essas operações usando uma unidade funcional profundamente em pipeline, um array de unidades funcionais paralelas ou uma combinação de unidades funcionais paralelas e em pipeline. A Figura 6.3 ilustra como melhorar o desempenho de vetor usando pipelines paralelas para executar uma instrução de soma vetorial.
As instruções de aritmética vetorial normalmente só permitem que o elemento N de um registrador de vetor tome parte das operações com o elemento N de outros registradores de vetor. Isso simplifica bastante a construção de uma unidade de vetor altamente paralela, que pode ser estruturada como múltiplas pistas de vetor paralelas. Como em uma rodovia de trânsito, podemos aumentar a vazão de pico de uma unidade de vetor acrescentando mais pistas. A Figura 6.4 mostra a estrutura de uma unidade de vetor com quatro pistas. Assim, a mudança de uma para quatro pistas reduz o número de clocks por instrução de vetor por um fator aproximado de quatro. Para que as múltiplas pistas sejam vantajosas, tanto as aplicações quanto a arquitetura precisam ter suporte para vetores longos. Caso contrário, elas serão executadas tão rapidamente que você ficará sem instruções, exigindo técnicas paralelas em nível de instrução, como aquelas do Capítulo 4, para fornecer instruções de vetor suficientes.
FIGURA 6.4 Estrutura de uma unidade de vetor contendo quatro pistas. O armazenamento do registrador de vetor é dividido pelas pistas, com cada pista mantendo cada quarto elemento de cada registrador de vetor. A figura mostra três unidades funcionais de vetor: adição de PF, multiplicação de PF e uma unidade de loadstore. Cada uma das unidades aritméticas de vetor contém quatro pipelines de execução, uma por pista, que atua em conjunto para completar uma única instrução de vetor. Observe como cada seção do arquivo de registrador de vetor precisa fornecer portas de leitura e escrita suficientes (Capítulo 4) para as unidades funcionais locais à sua pista.
pista de vetor Uma ou mais unidades funcionais de vetor e uma parte do arquivo de registradores de vetor. Inspiradas nas pistas das rodovias, que aumentam a velocidade do tráfego, as pistas múltiplas executam operações de vetor
simultaneamente.
Geralmente, as arquiteturas de vetor são um meio muito eficaz de executar programas de processamento paralelo de dados; elas combinam melhor com a tecnologia de compilador do que extensões de multimídia; e são mais fáceis de evoluir com o tempo do que as extensões de multimídia na arquitetura x86. Dadas essas categorias clássicas, em seguida examinamos como explorar os fluxos paralelos das instruções a fim de melhorar o desempenho de um único processador, que reutilizaremos com múltiplos processadores.
Verifique você mesmo Verdadeiro ou falso: conforme exemplificado no x86, as extensões de multimídia podem ser consideradas como uma arquitetura de vetor com vetores curtos que suportam apenas transferências contíguas de dados de vetor.
Detalhamento Dadas as vantagens do vetor, por que eles não são mais comuns fora da computação de alto desempenho? Havia preocupações sobre o estado maior
para registradores de vetor aumentando o tempo de troca de contexto e a dificuldade de tratar as falhas de página nos loads e stores de vetor, e as instruções SIMD conseguiram alguns dos benefícios das instruções de vetor. Além disso, enquanto os avanços no paralelismo em nível de instrução pudessem oferecer a promessa de desempenho da Lei de Moore, haveria pouco motivo para arriscar na mudança dos estilos de arquitetura.
Detalhamento Outra vantagem das extensões de vetor e multimídia é que é relativamente fácil estender uma arquitetura de conjunto de instruções escalar com essas instruções para melhorar o desempenho das operações paralelas com dados.
Detalhamento Os processadores x86 da geração Haswell da Intel têm suporte para AVX2, que possui uma operação “gather”, mas não uma operação “scatter”.
6.4. Multithreading do hardware Um conceito relacionado ao MIMD, especialmente pelo ponto de vista do programador, é o multithreading do hardware. Enquanto MIMD conta com múltiplos processos ou threads para tentar manter os diversos processadores ocupados, o multithreading do hardware permite que múltiplas threads compartilhem as unidades funcionais de um único processador de um modo sobreposto, para tentar utilizar os recursos do hardware de modo eficaz. Para permitir esse compartilhamento, o processador precisa duplicar o estado independente de cada thread. Por exemplo, cada thread teria uma cópia separada do banco de registradores e do contador de programa (PC). A memória em si pode ser compartilhada por meio de mecanismos de memória virtual, que já suportam multiprogramação. Além disso, o hardware precisa suportar a capacidade de mudar para uma thread diferente com relativa rapidez. Em especial, uma troca de thread deve ser muito mais eficiente do que uma troca de processo, que normalmente exige centenas a milhares de ciclos de processador, enquanto uma troca de thread pode ser instantânea.
multithreading do hardware
Aumentar a utilização de um processador trocando para outra thread quando uma thread é suspensa.
thread Uma thread inclui o contador de programa, o estado do registrador e a pilha. Ela é um processo simplificado; enquanto as threads normalmente compartilham um único espaço de endereços, o processo não faz isso.
processo Um processo inclui uma ou mais threads, o espaço de endereços e o estado do sistema operacional. Logo, uma comutação de processos normalmente invoca o sistema operacional, mas uma comutação de threads não. Existem dois métodos principais de multithreading do hardware. O multithreading fine-grained comuta entre threads a cada instrução, resultando em execução intercalada de várias threads. Essa intercalação normalmente é feita de forma circular, saltando quaisquer threads que estejam suspensas nesse ciclo de clock. Para tornar o multithreading fine-grained prático, o processador precisa ser capaz de trocar threads a cada ciclo de clock. Uma importante vantagem do multithreading fine-grained é que ele pode ocultar as perdas de vazão que surgem dos stalls curtos e longos, já que as instruções de outras threads podem ser executadas quando uma thread é suspensa. A principal desvantagem do multithreading fine-grained é que ele torna mais lenta a execução das threads individuais, já que uma thread que está pronta para ser executada sem stalls será atrasada por instruções de outras threads.
multithreading fine-grained Uma versão do multithreading do hardware que sugere a comutação entre as threads após cada instrução.
multithreading coarse-grained Uma versão do multithreading do hardware que sugere a comutação entre as threads somente após eventos significativos, como uma falha de cache de último nível.
O multithreading coarse-grained foi criado como uma alternativa para o multithreading fine-grained. Esse método de multithreading comuta threads apenas em stalls onerosos, como as falhas de cache de último nível. Essa mudança reduz a necessidade de tornar a comutação de thread essencialmente gratuita e tem muito menos chance de tornar mais lenta a execução de uma thread individual, visto que só serão despachadas instruções de outras threads quando uma thread encontrar um stall oneroso. Entretanto, o multithreading coarse-grained sofre de uma grande desvantagem: é limitado em sua capacidade de sanar perdas de vazão, especialmente de stalls mais curtos. Essa limitação surge dos custos de inicialização de pipeline do multithreading coarse-grained. Como um processador com multithreading coarse-grained despacha instruções por meio de uma única thread, quando ocorre um stall, o pipeline precisa ser esvaziado ou congelado. A nova thread que começa a ser executada após o stall precisa preencher o pipeline antes que as instruções consigam ser concluídas. Devido a esse overhead de inicialização, o multithreading coarse-grained é muito mais útil para reduzir a penalidade dos stalls de alto custo, em que a reposição de pipeline é insignificante comparada com o tempo de stall.
O simultaneous multithreading (SMT) é uma variação do multithreading do hardware que usa os recursos de um processador em pipeline, de despacho múltiplo, escalonado dinamicamente, para explorar paralelismo em nível de thread ao mesmo tempo em que explora o paralelismo em nível de instrução (Capítulo 4). O princípio mais importante que motiva o SMT é que os processadores de despacho múltiplo normalmente possuem mais paralelismo de unidade funcional do que uma única thread efetivamente pode usar. Além disso, com a renomeação de registradores e o escalonamento dinâmico (Capítulo 4), diversas instruções de threads independentes podem ser despachadas sem considerar as dependências entre elas; a resolução das dependências pode ser tratada pela capacidade de escalonamento dinâmico.
simultaneous multithreading (SMT) Uma versão do multithreading que reduz o custo do multithreading, utilizando os recursos necessários para a microarquitetura de despacho múltiplo, escalonada dinamicamente.
Por contar com os mecanismos dinâmicos existentes, SMT não troca de recurso a cada ciclo, mas sempre está executando instruções de múltiplas threads, deixando para o hardware a associação de slots de instrução e registradores renomeados com suas threads apropriadas. A Figura 6.5 ilustra conceitualmente as diferenças na capacidade de um processador de explorar recursos superescalares para as configurações de processador a seguir. A parte superior mostra como quatro threads seriam executadas de forma independente em um superescalar sem suporte a multithreading. A parte inferior mostra como as quatro threads poderiam ser combinadas para serem executadas no processador de maneira mais eficiente usando três opções de multithreading: ▪ Um superescalar com multithreading coarse-grained.
FIGURA 6.5 Como quatro threads usam os slots de despacho de um processador superescalar em diferentes métodos. As quatro threads no alto mostram como cada uma seria executada em um processador superescalar padrão sem suporte a multithreading. Os três exemplos embaixo mostram como elas seriam executadas juntas em três opções de multithreading. A dimensão horizontal representa a capacidade de despacho de instrução em cada ciclo de clock. A dimensão vertical representa uma sequência dos ciclos de clock. Uma caixa vazia (branca) indica que o slot de despacho correspondente está vago nesse ciclo de clock. Os tons de cinza e preto correspondem a quatro threads diferentes nos processadores multithreading. Os efeitos de inicialização de pipeline adicionais para multithreading coarse, que não estão ilustrados nessa figura, levariam a mais perda na vazão para multithreading coarse.
▪ Um superescalar com multithreading fine-grained. ▪ Um superescalar com simultaneous multithreading. No superescalar sem suporte a multithreading do hardware, o uso dos slots de despacho é limitado por uma falta de paralelismo em nível de instrução. Além disso, um importante stall, como uma falha de cache de instruções, pode deixar o processador inteiro ocioso.
No superescalar com multithreading coarse-grained, os longos stalls são parcialmente ocultados pela comutação para outra thread que usa os recursos do processador. Embora isso reduza o número de ciclos de clock completamente ociosos, o overhead de inicialização do pipeline ainda produz ciclos ociosos, e as limitações do paralelismo em nível de instrução significam que nem todos os slots de despacho serão utilizados. Em um processador com multithreading coarse-grained, a intercalação de threads elimina quase todos os ciclos de clock ociosos. Porém, como apenas uma thread despacha instruções em um determinado ciclo de clock, as limitações do paralelismo em nível de instrução ainda geram slots ociosos dentro de alguns ciclos de clock. No caso SMT, o paralelismo em nível de thread e o paralelismo em nível de instrução são explorados simultaneamente, com múltiplas threads usando os slots de despacho em um único ciclo de clock. O ideal é que o uso de slots de
despacho seja limitado por desequilíbrios nas necessidades e na disponibilidade de recursos entre múltiplas threads. Na prática, outros fatores podem restringir o número de slots usados. Embora a Figura 6.5 simplifique bastante a operação real desses processadores, ela ilustra as potenciais vantagens de desempenho em potencial do multithreading em geral e do SMT em particular. A Figura 6.6 representa graficamente os benefícios do multithreading em termos de desempenho e energia em um processador isolado do Intel Core i7 960, que possui suporte do hardware para duas threads. O speed-up médio é 1,31, que não é ruim, dados os modestos recursos extras para o multithreading do hardware. A melhoria média na eficiência de energia é 1,07, que é excelente. Em geral, você ficaria satisfeito com um speed-up neutro em termos de desempenho da energia.
FIGURA 6.6 O speed-up pelo uso do multithreading sobre um core em um processador i7 é 1,31 em média para os benchmarks PARSEC e a melhoria em termos da eficiência de energia é 1,07. Estes dados foram coletados e analisados por Esmaeilzadeh et. al. [2011].
Agora que já vimos como múltiplas threads podem utilizar os recursos de um único processador com mais eficiência, mostramos em seguida como usá-las
para explorar os processadores múltiplos.
Verifique você mesmo 1. Verdadeiro ou falso: tanto o multithreading quanto o multicore contam com o paralelismo para obter mais eficiência de um chip. 2. Verdadeiro ou falso: o multithreading simultâneo (SMT) utiliza threads para melhorar a utilização de recursos de um processador fora de ordem, escalonado dinamicamente.
6.5. Multicore e outros multiprocessadores de memória compartilhada Embora o multithreading do hardware melhorasse a eficiência dos processadores a um custo razoável, o grande desafio da última década tem sido entregar o potencial de desempenho da Lei de Moore programando de modo eficiente o número cada vez maior de processadores por chip. Dada a dificuldade de reescrever programas antigos para que funcionem bem em hardware paralelo, uma pergunta natural é o que os projetistas de computador podem fazer para simplificar a tarefa. Uma resposta para isso foi oferecer um único espaço de endereços físico que todos os processadores possam compartilhar, de modo que os programas não precisem se preocupar com o local onde são executados, apenas que podem ser executados em paralelo. Nessa técnica, todas as variáveis de um programa podem ficar disponíveis a qualquer momento para qualquer processador. A alternativa é ter um espaço de endereços separado por processador, o que requer que o compartilhamento seja explícito; vamos descrever essa opção na Seção 6.7. Quando o espaço de endereços físico é comum, então o hardware normalmente oferece coerência de cache para dar uma visão consistente da memória compartilhada (Seção 5.8). Como já dissemos, um multiprocessador de memória compartilhada (SMP) é aquele que oferece ao programador um único espaço de endereços físico para todos os processadores — que é o que quase sempre acontece para os chips multicore —, embora um termo mais preciso teria sido multiprocessador de endereço compartilhado. Os processadores se comunicam por meio de variáveis compartilhadas na memória, com todos os processadores capazes de acessar qualquer local da memória por meio de loads e stores. A Figura 6.7 mostra a organização clássica de um SMP. Observe que esses sistemas ainda podem
executar tarefas independentes em seus próprios espaços de endereços virtuais, mesmo que todos compartilhem um espaço de endereços físico.
FIGURA 6.7 Organização clássica de um multiprocessador de memória compartilhada.
Microprocessadores com único espaço de endereços podem ser de dois estilos. No primeiro, a latência para uma palavra na memória não depende de qual processador o solicite. Essas máquinas são chamadas multiprocessadores de acesso uniforme à memória (UMA). No segundo estilo, alguns acessos à memória são muito mais rápidos do que outros, dependendo de qual processador pede qual palavra, normalmente porque a memória principal é dividida e conectada a diferentes microprocessadores ou a diferentes controladores de memória no mesmo chip. Essas máquinas são chamadas multiprocessadores de acesso não uniforme à memória (NUMA). Como você poderia esperar, os desafios de programação são mais difíceis para um multiprocessador NUMA do que para um multiprocessador UMA, mas as máquinas NUMA podem expandir para tamanhos maiores, e as NUMAs podem ter latência inferior para a memória próxima.
acesso uniforme à memória (UMA) Um multiprocessador em que a latência a qualquer palavra na memória é aproximadamente a mesma, não importa qual processador solicita o acesso.
acesso não uniforme à memória (NUMA) Um tipo de multiprocessador com espaço de endereços único em que alguns acessos à memória são muito mais rápidos do que outros, dependendo de qual processador solicita qual palavra. Como os processadores operando em paralelo normalmente compartilharão dados, eles também precisam coordenar quando operarão sobre dados compartilhados; caso contrário, um processador poderia começar a trabalhar nos dados antes que outro tenha terminado. Essa coordenação é chamada de sincronização. Quando o compartilhamento tem o suporte de um único espaço de endereços, é preciso haver um mecanismo separado para sincronização. Uma técnica utiliza um lock para uma variável compartilhada. Somente um processador de cada vez pode adquirir o lock, e outros processadores interessados nos dados compartilhados precisam esperar até que o processador original libere a variável. A Seção 2.11 do Capítulo 2 descreve as instruções para o locking no conjunto de instruções MIPS.
sincronização O processo de coordenar o comportamento de dois ou mais processos, que podem estar sendo executados em diferentes processadores.
lock Um dispositivo de sincronização que permite o acesso aos dados somente por um processador de cada vez.
Um programa de processamento paralelo simples para um espaço de endereços compartilhado Exemplo Suponha que queremos somar 64.000 números em um computador com multiprocessador de memória compartilhada com tempo de acesso à memória uniforme. Vamos considerar que temos 64 processadores.
Resposta
Resposta A primeira etapa é garantir uma carga balanceada por processador, de modo que dividimos o conjunto de números em subconjuntos do mesmo tamanho. Não alocamos os subconjuntos a um espaço de memória diferente, já que existe uma única memória para essa máquina; apenas atribuímos endereços iniciais diferentes a cada processador. Pn é o número que identifica o processador, entre 0 e 63. Todos os processadores começam o programa executando um loop que soma seu subconjunto de números:
(Observe que o código C i += 1 é apenas uma abreviação de i = i + 1.) A próxima etapa é fazer essas 64 somas parciais. Essa etapa se chama redução, onde dividimos para conquistar. A metade dos processadores soma pares de somas parciais, depois, um quarto soma pares das novas somas parciais e assim por diante, até que tenhamos uma única soma final. A Figura 6.8 ilustra a natureza hierárquica dessa redução.
FIGURA 6.8 Os quatro últimos níveis de uma redução que soma os resultados de cada processador, de baixo para cima. Para todos os processadores cujo número i é menor que half, adicione a soma produzida pelo processador número (i + half) à sua soma.
Neste exemplo, os dois processadores precisam ser sincronizados antes que o processador “consumidor” tente ler o resultado do local da memória escrito pelo processador “produtor”; caso contrário, o consumidor pode ler o valor antigo dos dados. Queremos que cada processador tenha sua própria versão da variável contadora de loop i, de modo que precisamos indicar que ela é uma variável “privada”. Aqui está o código (half também é privada):
redução Uma função que processa uma estrutura de dados e retorna um único valor.
Interfacehardware/software Dado o interesse duradouro pela programação paralela, tem havido centenas de tentativas de se criar sistemas de programação paralelos. Um exemplo limitado, porém popular, é o OpenMP. Esta é simplesmente uma Interface de Programas de Aplicação (API) juntamente com um conjunto de diretivas de compilador, variáveis de ambiente e rotinas de biblioteca de runtime que podem estender as linguagens de programação padrão. Ela oferece um modelo de programação portátil, expansível e simples para os multiprocessadores de memória compartilhada. Seu principal objetivo é gerar loops paralelos e realizar reduções. A maioria dos compiladores C já tem suporte para OpenMP. O comando para usar a API OpenMP com o compilador C do UNIX é simplesmente: cc –fopenmp algo.c OpenMP estende a linguagem C usando pragmas, que são simplesmente comandos para o pré-processador de macros C, como #define e #include. Para definir o número de processadores que queremos usar como 64, como no exemplo anterior, simplesmente usamos o comando
Ou seja, as bibliotecas de runtime deverão usar 64 threads paralelas. Para transformar o loop for sequencial em um loop for paralelo que divide o trabalho igualmente entre todas as threads que dissemos que ele usaria, simplesmente escrevemos (considerando que sum seja inicializada em 0)
Para realizar a redução, podemos usar outro comando que diz ao OpenMP qual é o operador de redução e que variável você precisa usar para colocar o resultado da redução.
Observe que, agora, fica a cargo da biblioteca OpenMP encontrar o código para somar 64 números usando 64 processadores de modo eficiente. Embora o OpenMP facilite a escrita de um código paralelo simples, ele não é muito útil com a depuração, de modo que muitos programadores de código paralelo utilizam sistemas de programação paralela mais sofisticados do que o OpenMP, assim como muitos programadores hoje utilizam linguagens mais produtivas do que C.
OpenMP Uma API para multiprocessamento com memória compartilahda em C, C + + ou Fortran, que roda em plataformas UNIX e Microsoft. Ela inclui diretivas de compilador, uma biblioteca e diretivas de runtime. Dado esse passeio do hardware e software MIMD clássico, nosso próximo caminho será um passeio mais exótico por um tipo de arquitetura MIMD com uma herança diferente e, portanto, com uma perspectiva muito diferente no
desafio da programação paralela.
Verifique você mesmo Verdadeiro ou falso: multiprocessadores de memória compartilhada não podem tirar proveito do paralelismo em nível de tarefa.
Detalhamento Alguns escritores modificaram o acrônimo SMP para significar Symmetric Multiprocessor (multiprocessador simétrico), indicando que a latência do processador até a memória era aproximadamente a mesma para todos os processadores. Essa mudança foi feita para contrastá-los com os processadores NUMA em grande escala, pois ambas as classes usavam um único espaço de endereços. Como os clusters provaram ser muito mais populares do que os multiprocessadores NUMA em grande escala, neste livro, restauramos SMP para o seu significado original e o usamos para contrastar com aquele que usa múltiplos espaços de endereços, como os clusters.
Detalhamento Uma alternativa ao compartilhamento do espaço de endereços físico seria ter espaços de endereços físicos separados, mas compartilhar um espaço de endereços virtuais comum, deixando para o sistema operacional a tarefa de cuidar da comunicação. Essa técnica tem sido experimentada, mas possui um alto overhead para oferecer uma abstração de memória compartilhada prática ao programador orientado a desempenho.
6.6. Introdução às unidades de processamento de gráficos A justificativa original para o acréscimo de instruções SIMD às arquiteturas existentes foi que muitos microprocessadores eram conectados a telas gráficas em PCs e estações de trabalho, de modo que uma fração cada vez maior do tempo de processamento era usada para os gráficos. Daí, quando a Lei de Moore aumentou o número de transistores disponíveis aos microprocessadores, fez sentido melhorar o processamento gráfico.
Uma força motriz importante para melhorar o processamento gráfico foi a indústria de jogos de computador, tanto em PCs quanto em consoles de jogos dedicados, como o Play- Station da Sony. O mercado de jogos em rápido crescimento encorajou muitas empresas a fazerem investimentos cada vez maiores no desenvolvimento de hardware gráfico mais rápido, e esse feedback positivo levou o processamento gráfico a melhorar em um ritmo mais rápido do que o processamento de uso geral nos principais microprocessadores. Dado que a comunidade de gráficos e jogos teve objetivos diferentes da comunidade de desenvolvimento de microprocessador, ela evoluiu seu próprio estilo de processamento e terminologia. Quando os processadores gráficos aumentaram sua potência, eles ganharam o nome Graphics Processing Units, ou GPUs, para distingui-los das CPUs. Por algumas centenas de dólares, qualquer um pode comprar uma GPU hoje, com centenas de unidades paralelas de ponto flutuante, tornando mais acessível a computação de alto desempenho. O interesse na computação GPU prosperou quando esse potencial foi combinado com uma linguagem de programação que facilitou a programação das GPUs. Logo, muitos programadores de aplicações científicas e de multimídia atualmente estão pensando se irão usar GPUs ou CPUs. Aqui estão algumas das principais características de como as GPUs se distinguem das CPUs: ▪ GPUs são aceleradores que complementam uma CPU, de modo que não
precisam ser capazes de realizar todas as tarefas de uma CPU. Esse papel lhes permite dedicar todos os seus recursos aos gráficos. Não importa se as GPUs realizam algumas tarefas mal ou que não realizem, visto que, em um sistema com uma CPU e uma GPU, a CPU pode realizá-las se for preciso. ▪ Os tamanhos dos problemas que a GPU resolve normalmente são de centenas de megabytes a gigabytes, mas não centenas de gigabytes a terabytes. ▪ Estas diferenças levaram a diferentes estilos de arquitetura: ▪ Talvez a maior diferença seja que as GPUs não contam com caches multinível para contornar a longa latência para a memória, como nas CPUs. Em vez disso, as GPUs contam com o multithreading do hardware (Seção 6.4) para ocultar a latência para a memória. Ou seja, entre o momento de uma solicitação de memória e o momento em que os dados chegam, a GPU executa centenas ou milhares de threads que são independentes dessa solicitação. ▪ A memória da GPU é assim orientada para largura de banda, em vez de latência. Existem até mesmo chips de DRAM separados para GPUs que são mais largas e possuem largura de banda mais alta que os chips de DRAM para as CPUs. Além disso, as memórias da GPU tradicionalmente têm tido memória principal menor que os microprocessadores convencionais. Em 2013, as GPUs normalmente tinham de 4 a 6GiB ou menos, enquanto as CPUs tinham de 32 a 256GiB. Finalmente, lembre-se de que, para a computação de uso geral, você precisa incluir o tempo para transferir os dados entre a memória da CPU e a memória da GPU, pois a GPU é um coprocessador. ▪ Dada a confiança em muitos threads para oferecer boa largura de banda de memória, as GPUs podem acomodar muitos processadores paralelos (MIMD), além de muitas threads. Logo, cada processador de GPU é mais altamente multithreaded do que uma CPU típica, além de terem mais processadores.
Interface hardware/ software Embora as GPUs fossem projetadas para um conjunto mais estreito de aplicações, alguns programadores questionaram se poderiam especificar suas aplicações em uma forma que lhes permitissem aproveitar o alto desempenho em potencial das GPUs. Depois de cansar de tentar especificar seus problemas usando as APIs e linguagens gráficas, eles desenvolveram linguagens de
programação inspiradas em C para permitir que escrevam programas diretamente às GPUs. Um exemplo é a CUDA (Compute Unified Device Architecture) da NVIDIA, que permite que o programador escreva programas em C para execução nas GPUs, embora com algumas restrições. OpenCL é uma iniciativa multiempresarial para desenvolver uma linguagem de programação portável, que oferece muitos dos benefícios da linguagem CUDA. A NVIDIA decidiu que o tema unificador de todas essas formas de paralelismo é a Thread CUDA. Usando esse nível mais baixo de paralelismo como primitiva de programação, o compilador e o hardware podem reunir milhares de Threads CUDA para utilizar os diversos estilos de paralelismo dentro de uma GPU: multithreading, MIMD, SIMD e paralelismo em nível de instrução. Essas threads são colocadas em blocos e executadas em grupos de 32 de cada vez. Um processador multithreaded dentro de uma GPU executa esses blocos de threads, e uma GPU consiste de 8 a 32 desses processadores multithreaded.
Introdução à arquitetura de GPU NVIDIA Usamos sistemas NVIDIA como nosso exemplo porque representam bem as arquiteturas de GPU. Especificamente, seguimos a terminologia da linguagem de programação paralela CUDA e usamos a arquitetura Fermi como exemplo. Assim como as arquiteturas de vetor, as GPUs funcionam bem somente com problemas paralelos em nível de dados. Os dois estilos possuem transferências de dados gather-scatter, e os processadores GPU possuem ainda mais registradores do que os processadores de vetor. Ao contrário da maioria das arquiteturas de vetor, as GPUs também contam com o multithreading do hardware dentro de um único processador SIMD multithreaded para ocultar a latência da memória (Seção 6.4). Um processador SIMD multithreaded é semelhante a um processador de vetor, mas o primeiro possui muitas unidades funcionais paralelas, em vez de somente algumas com pipelines profundas, como o segundo. Como já dissemos, uma GPU contém uma coleção de processadores SIMD multithreaded; ou seja, uma GPU é um MIMD composto de processadores SIMD multithreaded. Por exemplo, NVIDIA possui quatro implementações da arquitetura Fermi com diferentes preços, com 7, 11, 14 ou 15 processadores SIMD multithreaded. Para fornecer escalabilidade transparente pelos modelos de
GPUs com diferentes números de processadores SIMD multithreaded, o hardware Thread Block Scheduler atribui blocos de threads aos processadores SIMD multithreaded. A Figura 6.9 mostra um diagrama de blocos simplificado de um processador SIMD multithreaded.
FIGURA 6.9 Diagrama de blocos simplificado do caminho de dados de um processador SIMD multithreaded. Ele possui 16 pistas SIMD. O SIMD Thread Scheduler possui muitas threads SIMD independentes, das quais ele escolhe para executar nesse processador.
Descendo mais um nível de detalhe, o objeto de máquina que o hardware cria, gerencia, escalona e executa é uma thread de instruções SIMD, que também chamaremos de thread SIMD. Essa é uma thread tradicional, mas contém instruções SIMD exclusivamente. Essas threads SIMD possuem seus próprios contadores de programa e são executadas em um processador SIMD multithreaded. O SIMD Thread Scheduler inclui um controlador que lhe permite saber quais threads de instruções SIMD estão prontas para ser executadas e, depois, as envia para uma unidade de despacho, para serem executadas no processador SIMD multithreaded. Ele é idêntico a um escalonador de threads de hardware de um processador multithreaded tradicional (Seção 6.4), exceto que está escalonando threads de instruções SIMD. Assim, o hardware de GPU tem dois níveis de escalonadores de hardware: 1. O Thead Block Scheduler que atribui blocos de threads aos processadores
SIMD multithreaded, e 2. o SIMD Thread Scheduler dentro de um processador SIMD, que escalona quando as threads SIMD deverão ser executadas. As instruções SIMD dessas threads possuem largura 32, de modo que cada thread de instruções SIMD calcularia 32 dos elementos do cálculo. Como a thread consiste em instruções SIMD, o processador SIMD precisa ter unidades funcionais paralelas para realizar a operação. Nós as chamamos de Pistas SIMD, e são muito semelhantes às pistas de vetor que vimos na Seção 6.3.
Detalhamento O número de pistas por processador SIMD varia no decorrer das gerações de GPU. Com Fermi, cada thread de instruções SIMD com largura 32 é mapeada para 16 pistas SIMD, de modo que cada instrução SIMD em uma thread de instruções SIMD usa dois ciclos de clock para ser concluída. Cada thread de instruções SIMD é executada em lock step. Continuando com a analogia de um processador SIMD como um processador de vetor, você poderia dizer que ele tem 16 pistas, e o comprimento do vetor seria 32. Essa natureza larga, porém superficial, é o motivo para usarmos o termo processador SIMD em vez de processador de vetor, pois é mais intuitivo. Como, por definição, as threads de instruções SIMD são independentes, o SIMD Thread Scheduler pode escolher qualquer thread de instruções SIMD que estiver pronta, e não precisa ficar preso à próxima instrução SIMD na sequência dentro de uma única thread. Logo, usando a terminologia da Seção 6.4, ele utiliza o multithreading fine-grained. Para segurar esses elementos da memória, um processador SIMD Fermi possui impressionantes 32.768 registradores de 32 bits. Assim como um processador de vetor, esses registradores são divididos logicamente pelas pistas de vetor ou, neste caso, pistas SIMD. Cada thread SIMD é limitada a não mais do que 64 registradores, de modo que você poderia pensar em uma thread SIMD como tendo até 64 registradores de vetor, com cada registrador de vetor tendo 32 elementos e cada elemento tendo 32 bits de largura. Como Fermi possui 16 pistas SIMD, cada uma contém 2048 registradores. Cada thread CUDA recebe um elemento de cada um dos registradores de vetor. Observe que uma thread CUDA é simplesmente um corte vertical de uma thread de instruções SIMD, correspondendo a um elemento executado por uma pista SIMD. Saiba que as threads CUDA são muito diferentes das
threads POSIX; não é possível fazer chamadas de sistema arbitrárias ou sincronizar arbitrariamente em uma thread CUDA.
Estruturas de memória da GPU NVIDIA A Figura 6.10 mostra as estruturas de memória de uma GPU NVIDIA. Chamamos a memória no chip, local a cada processador SIMD multithreaded, de memória local. Ela é compartilhada pelas pistas SIMD dentro de um processador SIMD multithreaded, mas essa memória não é compartilhada entre os processadores SIMD multithreaded. Chamamos a DRAM fora do chip, compartilhada pela GPU inteira e por todos os blocos de threads, de memória da GPU.
FIGURA 6.10 Estruturas de memória da GPU. A memória da GPU é compartilahda pelos loops vetorizados.
Todas as threads de instruções SIMD dentro de um bloco de threads compartilham a memória local.
Em vez de contar com caches grandes para conter os conjuntos de trabalho inteiros de uma aplicação, as GPUs tradicionalmente utilizam caches streaming menores, e contam com um extenso multithreading de instruções SIMD para ocultar a longa latência até a DRAM, pois esses conjuntos de trabalho podem ter centenas de megabytes. Logo, eles não caberão na cache de último nível de um microprocessador multicore. Dado o uso do multithreading de hardware para ocultar a latência da DRAM, a área do chip usada para caches nos processadores do sistema é gasta no lugar dos recursos de computação e no grande número de registradores para manter o estado de muitas threads das instruções SIMD.
Detalhamento Embora ocultar a latência da memória seja a filosofia básica, observe que as GPUs e os processadores de vetor mais recentes adicionaram caches. Por exemplo, a recente arquitetura Fermi adicionou caches, mas eles são considerados como filtros de largura de banda para reduzir as demandas sobre a memória da GPU ou como aceleradores para as poucas variáveis cuja latência não pode ser ocultada pelo multithreading. A memória local para quadros de pilha, chamadas de função e derramamento de registradores é uma boa utilização das caches, pois a latência é importante quando se chama uma função. As caches também economizam energia, pois os acessos à cache no chip gastam muito menos energia do que os acessos a vários chips de DRAM externos.
Colocando as GPUs em perspectiva Em um nível alto, os computadores multicore com extensões de instrução SIMD compartilham semelhanças com as GPUs. A Figura 6.11 resume as semelhanças e diferenças. Ambos são MIMDs cujos processadores utilizam várias pistas SIMD, embora as GPUs tenham mais processadores e muito mais pistas. Ambos utilizam o multithreading do hardware para melhorar a utilização do processador, embora as GPUs tenham suporte do hardware para muito mais threads. Ambos usam caches, embora as GPUs usem caches de streaming menores e os computadores multicore usem grandes caches multinível para tentar conter totalmente os conjuntos de trabalho inteiros. Ambos utilizam um
espaço de endereços de 64 bits, embora a memória principal física seja muito menor nas GPUs. Embora as GPUs suportem a proteção de memória no nível de página, elas ainda não suportam a paginação por demanda.
FIGURA 6.11 Semelhanças e diferenças entre multicore com extensões de multimídia SIMD e GPUs recentes.
Processadores SIMD também são semelhantes aos processadores de vetor. Os múltiplos processadores nas GPUs atuam como núcleos MIMD independentes, assim como muitos computadores de vetor possuem múltiplos processadores de vetor. Essa visão consideraria o Fermi GTX 580 como uma máquina de 16 núcleos com suporte do hardware para multithreading, onde cada núcleo possui 16 pistas. A maior diferença é o multithreading, que é fundamental para GPUs e não existe na maioria dos processadores de vetor. GPUs e CPUs não retornam, na genealogia da arquitetura de computação, para um ancestral comum; não existe um Elo Perdido que explica ambos. Como resultado dessa herança incomum, GPUs não têm usado os termos comuns na comunidade da arquitetura de computação, o que causou confusão sobre o que são GPUs e como elas funcionam. Para ajudar a acabar com a confusão, a Figura 6.12 (da esquerda para a direita) lista o termo mais descritivo usado nesta seção, o termo mais próximo da computação tradicional, o termo GPU oficial da NVIDIA, caso você esteja interessado, e depois uma pequena descrição do termo. Esta “Pedra de Roseta das GPU” poderá ajudar a relacionar esta seção e suas ideias às descrições mais convencionais da GPU.
FIGURA 6.12 Guia rápido para os termos referentes à GPU. Usamos a primeira coluna para os termos do hardware. Quatro grupos dividem esses 12 termos. De cima para baixo: Abstrações de Programa, Objetos de Máquina, Hardware de Processamento e Hardware de Memória.
Embora as GPUs estejam se movendo para o ramo principal da computação, elas não podem abandonar sua responsabilidade por continuar a se superar nos gráficos. Assim, o projeto de GPUs pode fazer mais sentido quando os arquitetos perguntam, dado o hardware investido para trabalhar bem com gráficos, como podemos complementá-lo a fim de melhorar o desempenho de uma gama maior de aplicações. Tendo abordado dois estilos diferentes de MIMD que possuem um espaço de
endereços compartilhado, a seguir, apresentamos os processadores paralelos, onde cada processador tem seu próprio espaço de endereços privado, facilitando bastante a criação de sistemas muito maiores. Os serviços de Internet que você usa diariamente dependem desses sistemas em larga escala.
Detalhamento Embora a GPU tenha sido apresentada como tendo uma memória separada da CPU, tanto a AMD quanto a Intel anunciaram produtos “agregados”, que combinam GPUs e CPUs para compartilhar uma única memória. O desafio será manter a memória com alta largura de banda em uma arquitetura mista, que foi um dos alicerces das GPUs.
Verifique você mesmo Verdadeiro ou falso: GPUs contam com chips de DRAM gráficos para reduzir a latência da memória e, portanto, aumentar o desempenho em aplicações gráficas.
6.7. Clusters, computadores em escala warehouse e outros multiprocessadores de passagem de mensagens A técnica alternativa ao compartilhamento de um espaço de endereços é que cada processador tenha seu próprio espaço privado de endereços físicos. A Figura 6.13 mostra a organização clássica de um multiprocessador com múltiplos espaços de endereços privados. Esse multiprocessador alternativo precisa se comunicar por meio da passagem de mensagens explícita, que tradicionalmente é o nome desse estilo de computadores. Desde que o sistema tenha rotinas para enviar e receber mensagens, a coordenação é embutida na passagem da mensagem, pois um processador sabe quando uma mensagem é enviada, e o processador receptor sabe quando uma mensagem chega. Se o emissor precisar de confirmação de que a mensagem chegou, o processador receptor poderá então enviar uma mensagem de confirmação para o emissor.
FIGURA 6.13 Organização clássica de um multiprocessador com múltiplos espaços de endereços privados, tradicionalmente chamado de multiprocessador de passagem de mensagens. Observe que, diferente do SMP da Figura 6.7, a rede de interconexão não está entre as caches e a memória, mas entre os nós processador-memória.
passagem de mensagens Comunicação entre vários processadores enviando e recebendo informações explicitamente.
rotina para enviar mensagem Uma rotina usada por um processador em máquinas com memórias privadas para passar uma mensagem a outro processador.
rotina para receber mensagem Uma rotina usada por um processador em máquinas com memórias privadas para aceitar uma mensagem de outro processador. Houve várias tentativas de construir computadores em larga escala com base em redes de passagem de mensagens de alto desempenho, e eles ofereceram
melhor desempenho de comunicação absoluta do que os clusters criados por meio de redes locais. Na verdade, muitos supercomputadores hoje utilizam redes customizadas. O problema é que eles são muito mais caros do que redes locais, como Ethernet. Poucas aplicações hoje, fora da computação de alto desempenho, poderiam justificar o desempenho de comunicação mais alto, dados os custos muito mais altos.
Interface hardware/software Computadores que contam com a passagem de mensagens para a comunicação, em vez da memória compartilhada coerente com a cache, são muito mais fáceis para os projetistas de hardware (Seção 5.8). A vantagem para os programadores é que a comunicação é explícita, o que significa que existem menos surpresas de desempenho do que com a comunicação implícita nos computadores de memória compartilhada coerentes com a cache. A desvantagem para os programadores é que é mais difícil transportar um programa sequencial para um computador com passagem de mensagens, pois cada comunicação precisa ser identificada antecipadamente, ou o programa não funcionará. A memória compartilhada coerente com a cache permite que o hardware descubra quais dados precisam ser comunicados, o que facilita o transporte. Existem diferenças de opinião quanto ao caminho mais curto para o alto desempenho, dados os prós e contras da comunicação implícita, mas não há confusão no mercado hoje. Os microprocessadores multicore utilizam memória física compartilhada e os nós de um cluster se comunicam entre si usando passagem de mensagens. Algumas aplicações concorrentes são bem executadas em hardware paralelo, independente de se ele oferece endereços compartilhados ou passagem de mensagens. Em particular, o paralelismo em nível de tarefa e aplicações com pouca comunicação — como busca na Web, servidores de correio e servidores de arquivos — não exigem que o endereçamento compartilhado funcione bem. Como resultado, os clusters se tornaram o exemplo mais divulgado atualmente do computador paralelo de passagem de mensagens. Dadas as memórias separadas, cada nó de um cluster executa uma cópia distinta do sistema operacional. Ao contrário, os núcleos dentro de um microprocessador são conectados usando uma rede de alta velocidade dentro do chip, e um sistema de memória compartilhada multichip utiliza a interconexão da memória para a
comunicação. A interconexão de memória possui maior largura de banda e menor latência, permitindo um desempenho de comunicação muito melhor para os multiprocessadores de memória compartilhada.
clusters Coleções de computadores conectados por E/S por switches de rede padrão para formar um multiprocessador de passagem de mensagens. O ponto fraco das memórias separadas para a memória do usuário de um ponto de vista da programação paralela se transforma em um ponto forte na estabilidade do sistema (Seção 5.5). Como um cluster consiste em computadores independentes conectados através de uma rede local, é muito mais fácil substituir um computador sem derrubar o sistema em um cluster do que em um multiprocessador de memória compartilhada. Fundamentalmente, o endereço compartilhado significa que é difícil isolar um processador e substituí-lo sem um trabalho heroico por parte do sistema operacional e no projeto físico do servidor. Também é fácil para os clusters reduzirem de tamanho de forma controlada quando um servidor falha, melhorando assim a estabilidade. Como o software do cluster é uma camada que roda em cima dos sistemas operacionais locais que rodam em cada computador, é muito mais fácil desconectar e substituir um computador defeituoso.
Como os clusters são construídos por meio de computadores inteiros e redes independentes e escaláveis, esse isolamento também facilita expandir o sistema sem paralisar a aplicação que executa sobre o cluster. Menor custo, maior disponibilidade e a rápida e gradual expansibilidade tornam os clusters atraentes para provedores de serviços de Internet, apesar de
seu desempenho de comunicação mais fraco em comparação com multiprocessadores de memória compartilhada em larga escala. Os mecanismos de busca que centenas de milhões de nós que utilizamos todos os dias dependem dessa tecnologia. Amazon, Facebook, Google, Microsoft e outros possuem múltiplos centros de dados, cada um com clusters de dezenas de milhares de processadores. É claro que o uso de múltiplos processadores nas empresas de serviço de Internet tem sido altamente bem-sucedido.
Computadores em escala warehouse Qualquer um pode construir uma CPU veloz. O truque é construir um sistema veloz. Seymour Cray, considerado o pai do supercomputador.
Os serviços de Internet, como aqueles que descrevemos acima, necessitavam da construção de novos prédios para abrigar, alimentar e resfriar 100.000 servidores. Embora eles possam ser classificados como apenas grandes clusters, arquitetura e operação são mais sofisticadas. Eles atuam como um computador gigante e custam na ordem de US$150 milhões para o prédio, a infraestrutura elétrica e de resfriamento, os servidores e o equipamento de rede que conecta e abriga 50.000 a 100.000 servidores. Nós os consideramos uma nova classe de computador, chamada computadores em escala warehouse (WSC — Warehouse-Scale Computers).
Interface hardware/ software A estrutura mais popular para o processamento batch em um WSC é MapReduce [Dean, 2008] e seu gêmeo open-source, Hadoop. Inspirado pelas funções Lisp com o mesmo nome, Map primeiro aplica uma função fornecida pelo programador a cada registro lógico de entrada. Map é executado em milhares de servidores para produzir um resultado intermediário de pares chave-valor. Reduce coleta a saída dessas tarefas distribuídas e as aglutina usando outra função definida pelo programador. Com suporte apropriado do software, ambos são altamente paralelos, embora fáceis de entender e usar. Dentro de 30 minutos, um programador iniciante pode executar uma tarefa MapReduce em milhares de servidores. Por exemplo, um programa MapReduce calcula o número de ocorrências de
cada palavra em inglês em uma grande coleção de documentos. A seguir encontra-se uma versão simplificada desse programa, que mostra apenas o loop mais interno e considera apenas uma ocorrência de todas as palavras em inglês encontradas em um documento:
A função EmitIntermediate usada na função Map emite cada palavra no documento e o valor um. Depois, a função Reduce soma todos os valores por palavra para cada documento, usando ParseInt() para obter o número de ocorrências por palavra em todos os documentos. O ambiente de runtime MapReduce escalona tarefas map e tarefas reduce aos servidores de um WSC. Nessa escala extrema, que requer inovação na distribuição de energia, resfriamento, monitoramento e operações, o WSC é um descendente moderno dos supercomputadores da década de 1970 — tornando Seymour Cray o padrinho dos arquitetos do WSC de hoje. Seus computadores extremos lidavam com cálculos que não podiam ser feitos em nenhum outro lugar, mas eram tão caros que apenas algumas poucas empresas poderiam pagar por eles. Atualmente, a meta é fornecer tecnologia de informação para o mundo, ao invés de computação de alto desempenho para cientistas e engenheiros. Logo, os WSCs certamente desempenham um papel mais importante para a sociedade hoje do que os supercomputadores de Cray no passado. Embora compartilhando algumas metas comuns com os servidores, os WSCs possuem três distinções principais:
1. Paralelismo Amplo e Fácil: Uma preocupação para um arquiteto de servidor é se as aplicações no mercado alvo possuem paralelismo suficiente para justificar a quantidade de hardware paralelo e se o custo é muito alto para que o hardware de comunicação suficiente explore esse paralelismo. Um arquiteto WSC não tem essa preocupação. Primeiro, aplicações batch como MapReduce beneficiam-se do grande número de conjuntos de dados independentes que precisam de processamento independente, como as bilhões de páginas Web a partir de um Web crawl. Segundo, aplicações de serviço interativo na Internet, também conhecidas como Software as a Service (SaaS), podem se beneficiar dos milhões de usuários independentes dos serviços interativos da Internet. Leituras e escritas raramente são dependentes no SaaS e, portanto, o SaaS raramente precisa de sincronização. Por exemplo, a busca usa um índice somente de leitura, e o e-mail normalmente está lendo e escrevendo informações independentes. Chamamos esse tipo de paralelismo fácil de Paralelismo em Nível de Solicitação, já que muitos esforços independentes podem prosseguir em paralelo naturalmente, com pouca necessidade de comunicação ou sincronização. 2. Custos Operacionais Contam: Tradicionalmente, arquitetos de servidor projetam seus sistemas para obter desempenho de pico dentro de um orçamento financeiro, e preocupam-se com a potência apenas para garantir que não excederão a capacidade de resfriamento de seus invólucros. Eles
normalmente ignoravam os custos operacionais de um servidor, supondo que são mínimos em comparação com os custos da compra. O WSC possui tempos de vida mais longos — o prédio e a infraestrutura elétrica e de resfriamento geralmente são amortizados por 10 ou mais anos —, de modo que os custos operacionais se acumulam: energia, distribuição de potência e resfriamento representam mais de 30% dos custos de um WSC durante 10 anos. 3. Escala e as Oportunidades/Problemas Associados com a Escala: Para construir um único WSC, é preciso comprar 100.000 servidores e também a infraestrutura de suporte, o que significa descontos por volume. Logo, os WSCs são tão grandes internamente que você consegue economia de escala mesmo que não haja muitos WSCs. Essas economias de escala levaram à computação em nuvem, já que os menores custos unitários de um WSC significavam que as empresas de nuvem poderiam alugar servidores a uma taxa lucrativa e ainda estar abaixo daquilo que custaria para os que desejam fazer isso externamente. O outro lado da oportunidade econômica da escala é a necessidade de lidar com a frequência de falha da expansão. Mesmo que um servidor tivesse um Tempo Médio Para a Falha de incríveis 25 anos (200.000 horas), o arquiteto WSC precisaria projetar para 5 falhas de servidor a cada ano. A Seção 5.13 mencionou uma taxa de falha de disco anual (AFR), medida no Google, de 2% a 4%. Se houvesse 4 discos por servidor e sua taxa de falha anual fosse 2%, o arquiteto WSC deveria esperar ver um disco falhando a cada hora. Assim, a tolerância a falhas é ainda mais importante para o arquiteto WSC do que para o arquiteto de servidor.
Software as a Service (SaaS) Em vez de vender software instalado e executar nos próprios computadores dos clientes, o software é executado em um local remoto e fica disponível pela Internet normalmente por meio de uma interface web com os clientes. Clientes SaaS são cobrados com base no uso, ao contrário da posse. As economias de escala desvendadas pelo WSC observaram o propósito há muito tempo sonhado da computação como um utilitário. A computação em nuvem significa que qualquer um, em qualquer lugar, com boas ideias, um modelo de negócios e um cartão de crédito pode aproveitar dos milhares de
servidores para oferecer sua visão quase instantaneamente para o mundo inteiro. Naturalmente, existem obstáculos importantes que poderiam limitar o crescimento da computação em nuvem — como a segurança, a privacidade, padrões e a taxa de crescimento da largura de banda da Internet —, mas podemos prever que isso está sendo tratado, de modo que os WSCs e a computação em nuvem possam crescer.Para entender melhor a taxa de crescimento da computação em nuvem, em 2012, a Amazon Web Services (AWS) anunciou que acrescenta nova capacidade de servidor a cada dia para dar suporte a toda a infraestrutura global da Amazon em 2003, quando a Amazon era uma empresa com receita anual de US$ 5,2 bilhões, com 6000 empregados. Agora que compreendemos a importância dos multiprocessadores por passagem de mensagens, especialmente para a computação em nuvem, em seguida veremos as formas de juntar os nós de um WSC. Graças à Lei de Moore e o número cada vez maior de núcleos por chip, agora precisamos também de redes dentro de um chip, de modo que essas topologias são importantes na grande escala e também na pequena.
Detalhamento A estrutura MapReduce embaralha e ordena os pares chave-valor no final da
fase Map, para produzir grupos que compartilhem a mesma chave. Esses grupos são então passados para a fase Reduce.
Detalhamento Outra forma de computação em grande escala é a computação em grade, em que os computadores são espalhados por grandes áreas, e depois os programas que executam neles precisam se comunicar por redes de longa distância. A forma mais comum e exclusiva de computação em grade foi promovida pelo projeto SETI@home. Observou-se que milhões de PCs ficam ociosos em determinado momento, sem realizar nada de útil, e eles poderiam ser apanhados e ter boa utilidade se alguém desenvolvesse software que pudesse rodar nesses computadores e depois dar a cada PC uma parte independente do problema para atuar. O primeiro exemplo foi o Search for ExtraTerrestrial Intelligence (SETI), ou busca por inteligência extraterreste, que foi lançado na UC Berkeley em 1999. Mais de 5 milhões de usuários de computador em mais de 200 países se inscreveram para o SETI@home, com mais de 50% fora dos EUA. Ao final de 2011, o desempenho médio da grade SETI@home era de 3,5 PetaFLOPS.
Verifique você mesmo 1. Verdadeiro ou falso: assim como os SMPs, os computadores com passagem de mensagens contam com locks para a sincronização. 2. Verdadeiro ou falso: os clusters possuem memória separadas e, portanto, precisam de muitas cópias do sistema operacional.
6.8. Introdução às topologias de rede multiprocessador Os chips multicore exigem que as redes nos chips conectem os núcleos, e os clusters exigem redes locais que conectem os servidores. Esta seção revisa os prós e os contras de diferentes topologias de redes de interconexão. Os custos de rede incluem o número de switches, o número de links em um switch que se conectam à rede, a largura (número de bits) por link, o tamanho dos links quando a rede é mapeada no chip. Por exemplo, alguns núcleos ou servidores podem ser adjacentes e outros podem estar no outro lado do chip ou no outro lado do centro de dados. O desempenho da rede também tem muitas faces. Ele inclui a latência em uma rede não carregada para enviar e receber uma mensagem, a vazão em termos do número máximo de mensagens que podem ser transmitidas em determinado período de tempo, atrasos causados pela disputa por uma parte da rede, e desempenho variável dependendo do padrão de comunicação. Outra obrigação da rede pode ser tolerância a falhas, pois os sistemas podem ter de operar na presença de componentes defeituosos. Finalmente, nesta era de chips de potência limitada, a eficiência de potência das diferentes organizações pode superar outros aspectos. As redes normalmente são desenhadas como gráficos, com cada arco do gráfico representando um link da rede de comunicação. Nas figuras desta seção, o nó processador-memória aparece como um quadrado preto, e o switch aparece como um círculo claro. Nesta seção, todos os links são bidirecionais; ou seja, a informação pode fluir em qualquer direção. Todas as redes consistem em switches cujos links vão para os nós processador-memória e para outros switches. A primeira rede conecta uma sequência de nós:
Essa topologia é chamada de anel. Como alguns nós não são conectados diretamente, algumas mensagens terão um salto por nós intermediários até que cheguem ao destino final.
Diferente de um barramento — um conjunto compartilhado de fios que permitem o broadcasting para todos os dispositivos conectados —, um anel é capaz de realizar muitas transferências simultâneas. Como existem diversas topologias para escolher, métricas de desempenho são necessárias a fim de distinguir esses projetos. Duas são comuns. A primeira é a largura de banda de rede total, que é a largura de banda de cada link multiplicado pelo número de links, e representa a largura de banda máxima. Para a rede de anel apresentada, com P processadores, a largura de banda de rede total seria P vezes a largura de banda do link; a largura de banda de rede total de um barramento é a largura de banda desse barramento.
largura de banda de rede Informalmente, a taxa de transferência máxima de uma rede; pode se referir à velocidade de um único link ou a taxa de transferência coletiva de todos os links na rede. Para balancear esse caso com melhor largura de banda, incluímos outra métrica que é mais próxima do pior caso: a largura de banda da corte. Esta é calculada dividindo-se a máquina em duas metades. Depois você soma a largura de banda dos links que cruzam essa linha divisória imaginária. A largura de banda de corte de um anel é duas vezes a largura de banda do link, e é uma vez a largura de banda de link para o barramento. Se um único link for tão rápido quanto o barramento, o anel tem apenas o dobro da velocidade de um barramento no pior caso, mas é P vezes mais rápido no melhor caso.
largura de banda de corte A largura de banda entre duas partes iguais de um multiprocessador. Essa medida é para uma divisão do multiprocessador no pior caso. Como algumas topologias de rede não são simétricas, surge a questão de onde desenhar a linha imaginária quando fizer o corte da máquina. A largura de banda de corte é uma métrica do pior caso, de modo que a resposta é escolher a divisão que gera o desempenho de rede mais pessimista. Em outras palavras, calcule todas as larguras de banda de corte possíveis e escolha a menor. Tomamos a visão pessimista porque programas paralelos normalmente são limitados pelo elo
mais fraco na cadeia de comunicação. No outro extremo de um anel está a rede totalmente conectada, em que cada processador tem um link bidirecional com cada outro processador. Para as redes totalmente conectadas, a largura de banda de rede total é P × (P – 1)/2, e a largura de banda de corte é (P/2)2.
rede totalmente conectada Uma rede que conecta nós processador-memória fornecendo um link de comunicação dedicado entre cada nó. A tremenda melhoria no desempenho das redes totalmente conectadas é anulada pelo enorme aumento no custo. Essa consequência inspira os engenheiros a inventarem novas topologias que estão entre o custo dos anéis e o desempenho das redes totalmente conectadas. A avaliação do sucesso depende em grande parte da natureza da comunicação na carga de trabalho de programas paralelos executados na máquina. O número de topologias diferentes que foram discutidas nas diversas publicações seria difícil de contar, mas somente uma minoria foi utilizada em processadores paralelos comerciais. A Figura 6.14 ilustra duas das topologias mais comuns.
FIGURA 6.14 Topologias de rede que apareceram nos processadores paralelos comerciais. Os círculos claros representam switches, e os quadrados pretos representam nós processador-memória. Embora um switch tenha muitos links, geralmente apenas um vai para o processador. A topologia booliana de n cubos é uma interconexão n-dimensional com 2n nós, exigindo n links por switch (mais um para o processador) e, portanto, n nós com o vizinho mais próximo. Constantemente, essas topologias básicas têm sido suplementadas com arcos extras para melhorar o desempenho e a confiabilidade.
Uma alternativa a colocar um processador em cada nó de uma rede é deixar apenas o switch em alguns desses nós. Os switches são menores que os nós processador-memória-switch, e assim podem ser mais bem compactados, encurtando assim a distância e aumentando o desempenho. Essas redes normalmente são chamadas redes multiestágio para refletir as múltiplas etapas em que uma mensagem pode trafegar. Os tipos de redes multiestágio são tão numerosos quanto as redes de único estágio; a Figura 6.15 ilustra duas das organizações multiestágio mais comuns. Uma rede crossbar permite que qualquer nó se comunique com qualquer outro nó em uma passada pela rede. Uma rede Ômega usa menos hardware do que a rede crossbar (2n log2n contra n2 switches), mas pode ocorrer disputa entre as mensagens, dependendo do padrão de comunicação. Por exemplo, a rede Ômega na Figura 6.15 não pode enviar uma mensagem de P0 a P6 ao mesmo tempo em que envia uma mensagem de P1 a P4.
FIGURA 6.15 Topologias comuns de rede multiestágio para oito nós. Os switches nesses desenhos são mais simples do que nos desenhos anteriores, pois os links são unidirecionais; os dados entram na parte de baixo e saem pelo link da direita. A caixa de switch em c pode passar A para C e B para D ou B para C e A para D. O crossbar usa n2 switches, em que n é o número de processadores, enquanto a rede Ômega usa 2n log2n caixas de switch grandes, cada uma composta logicamente por quatro dos switches menores. Nesse caso, o crossbar utiliza 64 switches contra 12 caixas de switch, ou 48 switches, na rede Ômega. O crossbar, porém, pode aceitar qualquer combinação de mensagens entre os processadores, enquanto a rede Ômega não pode.
rede multiestágio Uma rede que fornece um pequeno switch em cada nó.
rede crossbar Uma rede que permite que qualquer nó se comunique com qualquer outro nó em uma passada pela rede.
Implementando topologias de rede Nesta seção, a análise simples de todas as redes ignora considerações práticas importantes na elaboração de uma rede. A distância de cada link afeta o custo da comunicação com uma taxa de clock muito alta; geralmente, quanto maior a distância, mais cara é a manutenção de uma taxa de clock alta. Distâncias mais curtas também facilitam a atribuição de mais fios ao link, pois a potência necessária para usar muitos fios é menor se os fios forem curtos. Fios mais curtos também custam menos que os mais longos. Outra limitação prática é que os desenhos tridimensionais precisam ser mapeados em chips, cuja mídia é basicamente bidimensional. O último aspecto é o da energia. Questões relacionadas à energia, por exemplo, podem forçar os chips multicore a utilizarem topologias de grade simples. O resultado disso é que topologias que parecem ser elegantes quando desenhadas na prancheta podem ser impraticáveis quando construídas em silício ou em um centro de dados. Agora que compreendemos a importância dos clusters e vimos as topologias que os acompanham para conectá-los, em seguida, veremos o hardware e o software de interface da rede com o processador.
Verifique você mesmo Verdadeiro ou falso: Para um anel com P nós, a razão entre a largura de banda total da rede e a largura de banda de corte é P/2.
6.9. Benchmarks de multiprocessador e modelos de desempenho Como vimos no Capítulo 1, sistemas de benchmarking sempre é um assunto delicado, pois é uma forma altamente visível de tentar determinar qual sistema é
melhor. Os resultados afetam não apenas as vendas de sistemas comerciais, mas também a reputação dos projetistas desses sistemas. Logo, os participantes querem ganhar a competição, mas eles também querem ter certeza de que, se alguém mais ganhar, eles mereçam ganhar porque possuem um sistema genuinamente melhor. Esse desejo ocasiona regras para garantir que os resultados do benchmark não sejam simplesmente truques de engenharia para esse benchmark, mas, em vez disso, avanços que melhoram o desempenho de aplicações reais. Para evitar possíveis truques, uma boa regra é que você não pode mudar o benchmark. O código-fonte e os conjuntos de dados são fixos, e existe uma única resposta apropriada. Qualquer desvio dessas regras torna os resultados inválidos. Muitos benchmarks de multiprocessador seguem essas tradições. Uma exceção comum é ser capaz de aumentar o tamanho do problema de modo que você possa executar o benchmark em sistemas com um número bem diferente de processadores. Ou seja, muitos benchmarks permitem pouca facilidade de expansão, em vez de exigir muita facilidade, embora você deva ter cuidado ao comparar resultados para programas executando problemas com diferentes tamanhos. A Figura 6.16 é um resumo de vários benchmarks paralelos, também descritos a seguir: ▪ Linpack é uma coleção de rotinas de álgebra linear, e as rotinas para realizar a eliminação Gaussiana constituem o que é conhecido como benchmark Linpack. A rotina DGEMM no exemplo da Seção 3.5 representa uma pequena fração do código fonte do benchmark Linpack, mas é responsável pela maior parte do tempo de execução do benchmark. Ele permite expansão fraca, deixando que o usuário escolha qualquer tamanho de problema. Além do mais, ele permite que o usuário reescreva o Linpack em qualquer formato e em qualquer linguagem, desde que calcule o resultado apropriado e realize o mesmo número de operações de ponto flutuante para um problema de determinado tamanho. Duas vezes por ano, os 500 computadores com o desempenho Linpack mais rápido são publicados em www.top500.org. O primeiro nessa lista é considerado pela imprensa como o computador mais rápido do mundo.
FIGURA 6.16 Exemplos de benchmarks paralelos.
▪ SPECrate é uma métrica de vazão baseada nos benchmarks SPEC CPU, como SPEC CPU 2006 (Capítulo 1). Em vez de relatar o desempenho dos programas individuais, SPECrate executa muitas cópias do programa simultaneamente. Assim, ele mede o paralelismo em nível de tarefa, pois não há comunicação entre as tarefas. Você pode executar tantas cópias dos programas quantas desejar, de modo que essa novamente é uma forma de expansão fraca. ▪ SPLASH e SPLASH 2 (Stanford Parallel Applications for Shared Memory)
foram esforços realizados por pesquisadores na Stanford University na década de 1990 para reunir um conjunto de benchmarks paralelo, semelhante em objetivos ao conjunto de benchmarks SPEC CPU. Ele inclui kernels e aplicações, incluindo muitos advindos da comunidade de computação de alto desempenho. Esse benchmark requer expansão forte, embora venha com dois conjuntos de dados. ▪ Os benchmarks paralelos NAS (NASA Advanced Supercomputing) foram outra tentativa na década de 1990 de realizar o benchmark em multiprocessadores. Tomados da dinâmica de fluidos computacional, eles consistem em cinco kernels, e permitem expansão fraca, definindo alguns poucos conjuntos de dados. Assim como o Linpack, esses benchmarks podem ser reescritos, mas as regras exigem que a linguagem de programação só possa ser C ou Fortran. ▪ O recente conjunto de benchmarks PARSEC (Princeton Application Repository for Shared Memory Computers) consiste em programas multithreaded que usam Pthreads (POSIX threads) e OpenMP (Open MultiProcessing; veja a Seção 6.5). Eles focam em domínios computacionais emergentes e consistem de nove aplicações e três kernels. Oito contam com paralelismo de dados, três contam com paralelismo em pipeline, e um com paralelismo não estruturado. ▪ Tratando-se da nuvem, o objetivo do Yahoo! Cloud Serving Benchmark (YCSB) é comparar o desempenho dos serviços de dados da nuvem. Ele oferece um framework que facilita a execução do benchmark de novos serviços de dados pelos clientes, usando Cassandra e HBase como exemplos representativos. [Cooper, 2010]
Pthreads Uma API do UNIX para criar e manipular threads. Ela é estruturada como uma biblioteca. O lado negativo dessas restrições tradicionais dos benchmarks é que a inovação é limitada principalmente à arquitetura e compilador. Melhores estruturas de dados, algoritmos, linguagens de programação e assim por diante, geralmente, não podem ser usados, pois isso geraria um resultado ilusório. O sistema poderia ganhar, digamos, por causa do algoritmo, e não por causa do hardware ou do compilador.
Embora essas orientações sejam compreensíveis quando os alicerces da computação são relativamente estáveis — como eram na década de 1990 e na primeira metade da década seguinte —, elas são indesejáveis durante uma revolução da programação. Para que essa revolução tenha sucesso, precisamos encorajar a inovação em todos os níveis. Uma técnica foi defendida pelos pesquisadores na Universidade da Califórnia em Berkeley. Eles identificaram 13 padrões de projeto, que afirmam que será parte das aplicações do futuro. Esses padrões de projeto são implementados por frameworks ou kernels. Alguns exemplos são matrizes esparsas, grades estruturadas, máquinas de estados finitos, map reduce e travessia de gráfico. Mantendo as definições em um alto nível, elas esperam encorajar inovações em qualquer nível do sistema. Assim, o sistema com o solucionador de matriz esparsa mais rápido, fica livre para usar qualquer estrutura de dados, algoritmo e linguagem de programação, além de novas arquiteturas e compiladores.
Modelos de desempenho Um tópico relacionado aos benchmarks são os modelos de desempenho. Como temos visto com a crescente diversidade arquitetônica neste capítulo — multithreading, SIMD, GPUs —, seria especialmente útil se tivéssemos um modelo simples que oferecesse critérios para o desempenho de diferentes arquiteturas. Ele não precisa ser perfeito, apenas criterioso. O modelo 3Cs para o desempenho de cache, do Capítulo 5, é um exemplo de modelo de desempenho. Ele não é um modelo de desempenho perfeito, pois ignora fatores potencialmente importantes, como tamanho de bloco, diretiva de alocação de bloco e diretiva de substituição de bloco. Além do mais, ele possui algumas peculiaridades. Por exemplo, uma falta pode ser atribuída à capacidade em um projeto e a uma falha de conflito em outra cache com o mesmo tamanho. Mesmo assim, o modelo 3Cs já é popular há mais de 25 anos, pois oferece ideias para o comportamento dos programas, ajudando arquitetos e programadores a melhorarem suas criações com base em concepções desse modelo. Para encontrar tal modelo para computadores paralelos, vamos começar com kernels pequenos, como aqueles dos 13 padrões de projeto Berkeley, da Figura 6.16. Embora existam versões com diferentes tipos de dados para esses kernels, o ponto flutuante é popular em diversas implementações. Portanto, o desempenho de pico com ponto flutuante é um limite para a velocidade desses kernels em determinado computador. Para chips multicore, o desempenho de
pico com ponto flutuante é o desempenho de pico coletivo de todos os núcleos no chip. Se houvesse múltiplos microprocessadores no sistema, você multiplicaria o pico por chip pelo número total de chips. As demandas no sistema de memória podem ser estimadas dividindo-se esse desempenho de pico em ponto flutuante pelo número médio de operações de ponto flutuante por byte acessado:
A razão entre operações de ponto flutuante por byte de memória acessada é chamada de intensidade aritmética. Ela pode ser calculada apanhando-se o número total de operações de ponto flutuante para um programa dividido pelo número total de bytes de dados transferidos para a memória principal durante a execução do programa. A Figura 6.17 mostra a intensidade aritmética de vários dos padrões de projeto de Berkeley da Figura 6.16.
FIGURA 6.17 Intensidade aritmética, especificada como o número de operações de ponto flutuante para executar o programa dividido pelo número de bytes acessados na memória principal (Williams, Waterman e Patterson, 2009). Alguns kernels possuem uma intensidade aritmética que se expande com o tamanho do problema, como Matrizes Densidade, mas existem muitos kernels com intensidades aritméticas independentes do tamanho do problema. Para os
kernels nesse primeiro caso, a expansão fraca pode levar a diferentes resultados, pois coloca muito menos demanda sobre o sistema de memória.
intensidade aritmética A razão entre as operações de ponto flutuante em um programa e o número de bytes de dados acessados por um programa a partir da memória principal.
O modelo roofline Este modelo simples reúne desempenho de ponto flutuante, intensidade aritmética e desempenho da memória em um gráfico bidimensional (Williams, Waterman e Patterson, 2009). O desempenho de pico em ponto flutuante pode ser encontrado usando as especificações de hardware mencionadas anteriormente. Os conjuntos de trabalho dos kernels que consideramos aqui não se encaixam em caches no chip, de modo que o desempenho de pico da memória pode ser definido pelo sistema de memória por trás das caches. Um modo de encontrar o desempenho de pico da memória é o benchmark Stream. (Veja a seção Detalhamento na Seção “Tecnologia DRAM”, no Capítulo 5.) A Figura 6.18 mostra o modelo, que é feito uma vez para um computador, e não para cada kernel. O eixo-Y vertical é o desempenho de ponto flutuante alcançável de 0,5 a 64,0 GFLOPs/seg. O eixo-X horizontal é a intensidade aritmética, variando de 1/8 FLOPs/DRAM por byte acessado a 16 FLOPs/DRAM por byte acessado. Observe que o gráfico é uma escala log-log.
FIGURA 6.18 Modelo Roofline (Williams, Waterman e Patterson, 2009). Este exemplo tem um desempenho de pico em ponto flutuante de 16 GFLOPs/seg e uma largura de banda da memória de pico de 16 GB/seg do benchmark Stream. (Como o Stream na realidade tem quatro medições, essa linha é a média das quatro.) A linha vertical pontilhada à esquerda representa o Kernel 1, que tem uma intensidade aritmética de 0,5 FLOPs/byte. Ela é limitada pela largura de banda de memória a não mais que 8 GFLOPs/seg nesse Opteron X2. A linha vertical pontilhada à direita representa o Kernel 2, que tem uma intensidade aritmética de 4 FLOPs/byte. Ela é limitada apenas computacionalmente a 16 GFLOPs/seg. (Esses dados são baseados no AMD Opteron X2 (Revision F) usando dual cores executando a 2GHz em um sistema dual socket.)
Para determinado kernel, podemos encontrar um ponto no eixo X com base em sua intensidade aritmética. Se desenhássemos uma linha vertical passando por esse ponto, o desempenho do kernel nesse computador teria de ficar em algum lugar nessa linha. Podemos desenhar uma linha horizontal mostrando o desempenho de pico em ponto flutuante do computador. Obviamente, o desempenho real em ponto flutuante não pode ser maior que a linha horizontal, pois esse é um limite do hardware. Como poderíamos desenhar o desempenho de pico da memória, que é medido em bytes/seg? Como o eixo X é FLOPs/byte e o eixo Y é FLOPs/seg, bytes/seg é simplesmente uma linha diagonal em um ângulo de 45 graus nessa figura. Logo, podemos desenhar uma terceira linha que mostre o desempenho máximo em ponto flutuante que o sistema de memória desse computador pode suportar para determinada intensidade aritmética. Podemos expressar os limites como uma fórmula para desenhar a linha no gráfico da Figura 6.18:
As linhas horizontal e diagonal dão nome a esse modelo simples e indicam seu valor. A “roofline” (linha do telhado) define um limite superior no desempenho de um kernel, dependendo de sua intensidade aritmética. Dada uma roofline de um computador, você pode aplicá-la repetidamente, pois ela não varia por kernel. Se pensarmos na intensidade aritmética como um poste que atinge o telhado, ou ele atinge a parte inclinada do telhado, o que significa que o desempenho por fim está limitado pela largura de banda da memória, ou atinge a parte plana do telhado, o que significa que o desempenho é computacionalmente limitado. Na Figura 6.18, o kernel 1 é um exemplo do primeiro, e o kernel 2 é um exemplo do segundo. Observe que o “ponto de cumeeira”, em que os telhados diagonal e horizontal se encontram, oferece uma percepção interessante para o computador. Se for muito longe à direita, então somente os kernels com intensidade aritmética muito alta podem alcançar o desempenho máximo desse computador. Se for muito à esquerda, então quase todo kernel poderá potencialmente atingir o desempenho máximo.
Comparando duas gerações de Opterons
Comparando duas gerações de Opterons O AMD Opteron X4 (Barcelona) com quatro núcleos é o sucessor do Opteron X2 com dois núcleos. Para simplificar o projeto da placa, eles usam o mesmo soquete. Logo, eles possuem os mesmos canais de DRAM e, portanto, a mesma largura de banda de memória de pico. Além de dobrar o número de núcleos, o Opteron X4 também tem o dobro do desempenho de pico em ponto flutuante por núcleo: os núcleos do Opteron X4 podem emitir duas instruções SSE2 de ponto flutuante por ciclo de clock, enquanto os núcleos do Opteron X2 emitem no máximo uma. Como os dois sistemas que estamos comparando possuem taxas de clock semelhantes — 2,2GHz para o Opteron X2 contra 2,3GHz para o Opteron X4 — o Opteron X4 tem cerca de quatro vezes o desempenho de ponto flutuante de pico do Opteron X2 com a mesma largura de banda de DRAM. O Opteron X4 também tem uma cache L3 de 2MiB, que não é encontrada no Opteron X2. A Figura 6.19 compara os modelos roofline para ambos os sistemas. Como poderíamos esperar, o ponto de cumeeira move-se para a direita, passando de 1 no Opteron X2 para 5 no Opteron X4. Logo, para ver um ganho de desempenho na próxima geração, os kernels precisam de uma intensidade aritmética maior que 1, ou seus conjuntos de trabalho terão de caber nas caches do Opteron X4.
FIGURA 6.19 Modelos roofline de duas gerações de Opterons. A roofline do Opteron X2, que é a mesma que na Figura 6.18, está em preto, e a roofline do Opteron X4 está em cinza claro. O ponto de cumeeira mais alto do Opteron X4 significa que os kernels que eram computacionalmente limitados no Opteron X2 poderiam ser limitados pelo desempenho da memória no Opteron X4.
O modelo roofline oferece um limite superior para o desempenho. Suponha
que seu programa esteja muito abaixo desse limite. Que otimizações você deverá realizar, e em que ordem? Para reduzir os gargalos computacionais, as duas otimizações a seguir podem ajudar a quase todo kernel:
1. Mix de operações de ponto flutuante. O desempenho de pico em ponto flutuante para um computador normalmente exige um número igual de adições e multiplicações quase simultâneas. Esse equilíbrio é necessário ou porque o computador admite uma instrução multiplicação-adição unificada (veja a seção Detalhamento na Seção “Aritmética de precisão”, no Capítulo 3) ou porque a unidade de ponto flutuante tem um número igual de somadores de ponto flutuante e multiplicadores de ponto flutuante. O melhor desempenho também requer que uma fração significativa do mix de instruções seja de operações de ponto flutuante, e não instruções de inteiros. 2. Melhore o paralelismo em nível de instrução e aplique SIMD. Para arquiteturas superescalares, o desempenho mais alto surge com a busca, execução e comprometimento de três a quatro instruções por ciclo de clock (Seção 4.10). O objetivo aqui é melhorar o código do compilador para aumentar o ILP. Uma forma é desdobrando loops. Para as arquiteturas x86,
uma única instrução AVX pode operar sobre operandos de precisão dupla, de modo que elas devem ser usadas sempre que possível (Seções 3.7 e 3.8). Para reduzir os gargalos da memória, as duas otimizações a seguir podem ajudar:
1. Pré-busca do software. Normalmente, o desempenho mais alto exige manter muitas operações da memória no ato, que é mais fácil de se fazer executando acessos de predição via instruções de pré-busca do software, em vez de esperar até que os dados sejam exigidos pela computação. 2. Afinidade de memória. A maioria dos microprocessadores de hoje inclui um controlador de memória no mesmo chip com o microprocessador, o que melhora o desempenho da hierarquia de memória. Se o sistema tiver múltiplos chips, isso significa que os mesmos endereços vão para a DRAM que é local a um chip, e o restante requer que os acessos pela interconexão do chip acessem a DRAM que é local a outro chip. Essa divisão resulta em
acessos não uniformes à memória, que descrevemos na Seção 6.5. O acesso à memória através de outro chip reduz o desempenho. Essa segunda otimização tenta alocar dados e as threads encarregadas de operar sobre esses dados no mesmo par memória-processador, de modo que os processadores raramente precisam acessar a memória dos outros chips.
O modelo roofline pode ajudar a decidir quais dessas otimizações serão realizadas e em que ordem. Podemos pensar em cada uma dessas otimizações como um “teto” abaixo da roofline apropriada, significando que você não pode ultrapassar um teto sem realizar a otimização associada. A roofline computacional pode ser encontrada nos manuais, e a roofline de memória pode ser encontrada executando-se o benchmark Stream. Os tetos computacionais, como o equilíbrio de ponto flutuante, também vêm dos manuais desse computador. O teto de memória, como a afinidade de memória, exige a execução de experimentos em cada computador, para determinar a lacuna entre eles. A boa notícia é que esse processo só precisa ser feito uma vez por computador, pois quando alguém caracterizar os tetos de um computador, todos poderão usar os resultados a fim de priorizar suas otimizações para esse computador. A Figura 6.20 acrescenta tetos ao modelo roofline da Figura 6.18, mostrando os tetos computacionais no gráfico superior e os tetos da largura de banda de
memória no gráfico inferior. Embora os tetos mais altos não sejam rotulados com as duas otimizações, isso está implícito nessa figura; para ultrapassar o teto mais alto, você já deverá ter ultrapassado todos os tetos abaixo.
FIGURA 6.20 Modelo roofline com tetos. O gráfico superior mostra os “tetos” computacionais de 8 GFLOPs/seg, se o mix de operações de ponto flutuante estiver desequilibrado e 2 GFLOPs/seg, se as otimizações para aumentar o ILP e o SIMD também estiverem faltando. O gráfico inferior mostra os tetos de largura de banda da memória de 11 GB/seg sem pré-busca de software e 4,8 GB/seg, se as otimizações de afinidade de memória também estiverem faltando.
A espessura da lacuna entre o teto e o próximo limite mais alto é a recompensa por tentar essa otimização. Assim, a Figura 6.20 sugere que a otimização 2, que melhora o ILP, tem um grande benefício para melhorar a computação nesse computador, e a otimização 4, que melhora a afinidade de memória, tem um grande benefício para melhorar a largura de banda da memória nesse computador. A Figura 6.21 combina os tetos da Figura 6.20 em um único gráfico. A intensidade aritmética de um kernel determina a região de otimização, que, por sua vez, sugere quais otimizações tentar. Observe que as otimizações computacionais e as otimizações de largura de banda da memória se sobrepõem para grande parte da intensidade aritmética. Três regiões são sombreadas de formas diferentes na Figura 6.21 para indicar as diferentes estratégias de otimização. Por exemplo, o Kernel 2 cai no trapezoide cinza claro à direita, que sugere trabalhar apenas nas otimizações computacionais. O Kernel 1 cai no paralelogramo cinza escuro no meio, que sugere tentar os dois tipos de otimização. Além do mais, ele sugere começar com as otimizações 2 e 4. Observe que as linhas verticais do Kernel 1 caem abaixo da otimização de desequilíbrio de ponto flutuante, de modo que a otimização 1 pode ser desnecessária. Se um kernel caísse no triângulo cinza no canto inferior esquerdo, isso sugeriria tentar apenas otimizações de memória.
FIGURA 6.21 Modelo roofline com tetos, áreas sobrepostas sombreadas e os dois kernels da Figura 6.18. Os kernels cuja intensidade aritmética se encontra no trapezoide azul claro à direita deverão focalizar otimizações de computação, e os kernels cuja intensidade aritmética se encontra no triângulo cinza no canto inferior esquerdo devem focalizar otimizações de largura de banda de memória. Aqueles que se encontram no paralelogramo cinza escuro no meio precisam se preocupar com ambos. Quando o Kernel 1 cai no paralelogramo do meio, tente otimizar ILP e SIMD, afinidade de memória e pré-busca de software. O Kernel 2 cai no trapezoide à direita; portanto, tente otimizar ILP e SIMD e o equilíbrio das operações de ponto flutuante.
Até aqui, estivemos supondo que a intensidade aritmética é fixa, mas esse não
é realmente o caso. Primeiro, existem kernels cuja intensidade aritmética aumenta com o tamanho do problema, como para os problemas Matriz Densidade e N-body (Figura 6.17). Na realidade, esse pode ser o motivo para os programadores terem mais sucesso com a expansão fraca do que com a expansão forte. Segundo, a eficácia da hierarquia de memória afeta o número de acessos que vão para a memória, de modo que as otimizações que melhoram o desempenho da cache também melhoram a intensidade aritmética. Um exemplo é melhorar a localidade temporal desdobrando loops e depois agrupando instruções com endereços semelhantes. Muitos computadores possuem instruções de cache especiais, que alocam dados em uma cache, mas não preenchem primeiro os dados da memória nesse endereço, pois eles logo serão modificados. Essas duas otimizações reduzem o tráfego da memória, movendo assim o poste da intensidade aritmética para a direita por um fator de, digamos, 1,5. Esse deslocamento para a direita poderia colocar o kernel em uma região de otimização diferente.
Embora os exemplos anteriores mostrem como ajudar os programadores a melhorarem o desempenho, o modelo também pode ser usado por arquitetos para decidir onde eles otimizariam o hardware para melhorar o desempenho dos kernels que acreditam que serão importantes. A próxima seção usa o modelo roofline para demonstrar a diferença de
desempenho entre um microprocessador multicore e uma GPU, e para ver se essas diferenças refletem o desempenho de programas reais.
Detalhamento Os tetos são ordenados de modo que os mais baixos são mais fáceis de otimizar. Logicamente, um programador pode otimizar em qualquer ordem, mas ter essa sequência reduz as chances de desperdiçar esforço em uma otimização que não possui benefício devido a outras restrições. Assim como no modelo 3Cs, desde que o modelo roofline possa gerar esclarecimentos, um modelo pode ter suposições que provam ser otimistas. Por exemplo, o modelo supõe que o programa tem balanceamento de carga entre todos os processadores.
Detalhamento Uma alternativa ao benchmark Stream é usar a largura de banda bruta da DRAM como roofline. Enquanto a largura de banda bruta definitivamente é um limite superior rígido, o desempenho real da memória normalmente está tão distante desse limite que não é tão útil como um limite superior. Ou seja, nenhum programa pode chegar perto desse limite. A desvantagem de usar o Stream é que uma programação muito cuidadosa pode exceder os resultados do Stream, de modo que a roofline da memória pode não ser um limite tão rígido quanto a roofline computacional. Ficamos com o Stream porque menos programadores serão capazes de oferecer mais largura de banda de memória do que o Stream descobre.
Detalhamento Embora o modelo roofline apresentado seja para processadores multicores, ele certamente também funcionaria para um uniprocessador.
Verifique você mesmo Verdadeiro ou falso: a principal desvantagem com as técnicas convencionais de benchmarks para computadores paralelos é que as regras que garantem imparcialidade também suprimem a inovação do software.
6.10. Vida real: benchmarking e rooflines do Intel Core i7 960 e GPU NVIDIA Tesla Um grupo de pesquisadores da Intel publicou um artigo (Lee et al., 2010) comparando um Intel Core i7 960 quad-core com extensões SIMD de multimídia com a GPU da geração anterior, o NVIDIA Tesla GTX 280. A Figura 6.22 lista as características dos dois sistemas. Os dois produtos foram comprados no segundo semestre de 2009. O Core i7 está na tecnologia de semicondutores de 45 nanômetros da Intel, enquanto a GPU está na tecnologia de 65 nanômetros da TSMC. Embora pudesse ter sido mais justo a comparação ser feita por um terceiro agente ou por ambas as partes interessadas, o propósito desta seção não é determinar o quanto mais rápido um produto é em relação ao outro, mas tentar entender o valor relativo dos recursos desses dois estilos de arquitetura contrastantes.
FIGURA 6.22 Especificações do Intel Core i7-960, NVIDIA GTX 280 e GTX 480. As duas colunas da direita mostram as razões entre o Tesla GTX 280 e do Fermi GTX 480 e o Core i7. Embora o estudo de caso seja entre o Tesla 280 e o i7, incluímos o Fermi 480 para mostrar sua relação com o Tesla 280, pois ele foi descrito neste capítulo. Observe que essas larguras de banda de memória são mais altas do que na Figura 6.23, pois estas são larguras de banda
nos pinos da DRAM, e as da Figura 6.23 são nos processadores, conforme medidas por um programa de benchmark. (Da Tabela 2 em Lee et al. [2010]).
As rooflines do Core i7 960 e do GTX 280 na Figura 6.23 ilustram as diferenças nos computadores. Não apenas o GTX 280 possui muito mais largura de banda de memória e desempenho de ponto flutuante em precisão dupla, mas também seu ponto de cumeeira de precisão dupla se localiza consideravelmente à esquerda. O ponto de cumeeira de precisão dupla é 0,6 para o GTX 280, contra 3,1 para o Core i7. Como já dissemos, quanto mais distante da esquerda estiver o ponto de cumeeira da roofline, mais fácil será atingir o desempenho computacional de pico. Para o desempenho em precisão simples, o ponto de cumeeira se move mais para a direita nos dois computadores, de modo que é muito mais difícil atingir o telhado do desempenho em precisão simples. Observe que, a intensidade aritmética do kernel é baseada nos bytes que vão para a memória principal, e não os bytes que vão para a memória cache. Assim, como já dissemos, o caching pode alterar a intensidade aritmética de um kernel em determinado computador, se a maioria das referências realmente forem para a cache. Observe também que essa largura de banda é para acessos com stride unitário nas duas arquiteturas. Os endereços gather-scatter reais podem ser mais baixos no GTX 280 e no Core i7, conforme veremos.
FIGURA 6.23 Modelo roofline (Williams, Waterman e Patterson, 2009). Essas rooflines mostram o desempenho de ponto flutuante, em precisão dupla na fileira superior e o desempenho em precisão simples na fileira inferior. (O teto do desempenho de PF em PD também está na fileira inferior, por comparação.) O Core i7 960 à esquerda tem um desempenho de PF em PD de 51,2 GFLOP/seg, um pico de PF em OS de 102,4 GFLOP/seg e uma largura de banda de memória de pico de 16,4 GBytes/seg. O NVIDIA GTX 280 tem um pico de PF em PD de 78 GFLOP/seg, pico de PF em PS de 624 GFLOP/seg e 127 GBytes/seg de largura de banda da memória. A linha vertical tracejada à esquerda representa uma intensidade aritmética de 0,5 FLOP/byte. Ela é limitada pela largura de banda da memória a não mais que 8 PD GFLOP/seg ou 8 PS GFLOP/seg no Core i7. A linha vertical tracejada à direita tem uma intensidade
aritmética de 4 FLOP/byte. Ela é limitada apenas computacionalmente a 51,2 PD GFLOP/seg e 102,4 PS GFLOP/seg no Core i7 e 78 PD GFLOP/seg e 512 PS GFLOP/seg no GTX 280. para alcançar a taxa de computação mais alta no Core i7, você precisa usar todos os 4 núcleos e instruções SSE com o mesmo número de multiplicações e adições. Para o GTX 280, você precisa usar instruções combinadas de multiplicação-adição em todos os processadores SIMD multithreaded.
Os pesquisadores selecionaram os programas de benchmark analisando as características computacionais e de memória de quatro pacotes de benchmark propostos recentemente, e então “formularam o conjunto de kernels de cálculo da vazão, que capturam essas características”. A Figura 6.24 mostra os resultados do desempenho, com números maiores significando mais rápido. As rooflines ajudam a explicar o desempenho relativo nesse estudo de caso.
FIGURA 6.24 Desempenho bruto e relativo medido para as duas plataformas. Neste estudo, SAXPY é usado simplesmente como uma medida de largura de banda da memória, de modo que a unidade da direita é GBytes/seg e não GFLOP/seg. (Baseado na Tabela 3 em [Lee et al., 2010].)
Dado que as especificações brutas de desempenho do GTX 280 variam de 2,5× mais lentas (taxa de clock) a 7,5× mais rápidas (núcleos por chip), enquanto o desempenho varia de 2,0× mais lento (Solv) até 15,2× mais rápido (GJK), os pesquisadores da Intel decidiram encontrar os motivos para as diferenças: ▪ Largura de banda da memória. A GPU tem 4,4× a largura de banda da memória, o que ajuda a explicar por que LBM e SAXPY são executados 5,0 e 5,3× mais rápido; seus conjuntos de trabalho são centenas de megabytes e, portanto, não cabem na cache do Core i7. (Logo, quanto ao acesso intensivo à memória, eles propositalmente não usaram o bloqueio de cache, como no Capítulo 5.) Logo, a inclinação das rooflines explicam seu desempenho.
SpMV também possui um grande conjunto de trabalho, mas só executa 1,9× mais rápido, pois o ponto flutuante de precisão dupla do GTX 280 é apenas 1,5× mais rápido que o Core i7. ▪ Largura de banda de cálculo. Cinco dos kernels restantes são limitados pelo cálculo: SGEMM, Conv, FFT, MC e Bilat. O GTX é mais rápido por 3,9, 2,8, 3,0, 1,8 e 5,7 × , respectivamente. Os três primeiros utilizam aritmética de ponto flutuante com precisão simples, e a precisão simples do GTX 280 é de 3 a 6× mais rápida. MC usa precisão dupla, o que explica por que ele é apenas 1,8× mais rápido, pois o desempenho em PD é apenas 1,5× mais rápido. Bilat usa funções transcendentais, para as quais o GTX 280 tem suporte direto. O Core i7 gasta dois terços de seu tempo calculando funções transcendentais para o Bilat, de modo que o GTX 280 é 5,7× mais rápido. Essa observação ajuda a explicar o valor do suporte do hardware para operações que ocorrem na sua carga de trabalho: ponto flutuante com precisão dupla e talvez até mesmo transcendentais. ▪ Benefícios da cache. Ray casting (RC) é apenas 1,6× mais rápido no GTX, porque o bloqueio da cache com as caches do Core i7 impede que ele se torne limitado pela largura de banda da memória (Seções 5.4 e 5.14), como nas GPUs. O bloqueio de cache também pode ajudar no Search. Se as árvores de índice forem pequenas, de modo que caibam na cache, o Core i7 terá o dobro da velocidade. Árvores de índice maiores os tornam limitados pela largura de banda da memória. Em geral, o GTX 280 executa a busca 1,8× mais rápido. O bloqueio de cache também ajuda no Sort. Embora a maioria dos programadores não executaria o Sort em um processador SIMD, ele pode ser escrito com uma primitiva Sort de 1 bit, chamada split. Porém, o algoritmo split executa muito mais instruções do que uma ordenação escalar. Como resultado, o Core i7 executa 1,25× mais rápido que o GTX 280. Observe que as caches também ajudam outros kernels no Core i7, pois o bloqueio de cache permite que SGEMM, FFT e SpMV se tornem limitados pelo cálculo. Essa observação enfatiza novamente a importância das otimizações por bloqueio de cache do Capítulo 5. ▪ Gather-Scatter. As extensões SIMD de multimídia ajudam pouco se os dados estiverem espalhados pela memória principal; o desempenho ideal vem somente quando os acessos aos dados são alinhados em limites de 16 bytes. Assim, GJK tem pouco benefício com o SIMD no Core i7. Como já dissemos, as GPUs oferecem endereçamento gather-scatter, que é encontrado em uma arquitetura de vetor, mas não aparece na maioria das
extensões SIMD. O controlador de memória até mesmo reúne os acessos na mesma página da DRAM em batch (Seção 5.2). Essa combinação significa que o GTX 280 roda o GJK com surpreendentes 15,2× mais rápido do que o Core i7, o que é maior do que qualquer parâmetro físico isolado na Figura 6.22. Esta observação reforça a importância do gather-scatter para as arquiteturas de vetor e GPU, que não existe nas extensões SIMD. ▪ Sincronização. O desempenho da sincronização é limitado pelas atualizações atômicas, que são responsáveis por 28% do runtime total no Core i7, mesmo tendo uma instrução de busca e incremento no hardware. Assim, Hist é apenas 1,7× mais rápido no GTX 280. Solv resolve um lote de restrições independentes em uma pequena quantidade de cálculo, seguida por sincronização de barreira. O Core i7 se beneficia das instruções atômicas e um modelo de consistência de memória que garante resultados corretos, mesmo que nem todos os acessos anteriores à hierarquia de memória tenham sido concluídos. Sem o modelo de consistência de memória, a versão do GTX 280 dispara alguns batches a partir do processador do sistema, o que leva ao GTX 280 rodando com a metade da velocidade do Core i7. Essa observação explica como o desempenho da sincronização pode ser importante para alguns problemas de paralelismo de dados. É surpreendente a frequência com que os pontos fracos no Tesla GTX 280, que foram desvendados pelos kernels selecionados pelos pesquisadores da Intel, já haviam sido tratados na arquitetura sucessora do Tesla: Fermi tem desempenho mais rápido em ponto flutuante com precisão dupla, operações atômicas mais rápidas e caches. Também foi interessante que o suporte para gather-scatter das arquiteturas de vetor, décadas antes das instruções SIMD, foi tão importante para a utilidade efetiva dessas extensões SIMD, que alguns já haviam previsto antes da comparação. Os pesquisadores da Intel observaram que 6 dos 14 kernels explorariam o SIMD melhor com o suporte para gather-scatter mais eficiente no Core i7. Esse estudo certamente estabelece a importância também do bloqueio de cache. Agora que vimos uma grande gama de resultados de benchmarking com diferentes multiprocessadores, vamos voltar ao nosso exemplo do DGEMM, para ver com detalhes o quanto temos que mudar o código C para tirar proveito dos múltiplos processadores.
6.11. Mais rápido: processadores múltiplos e
multiplicação matricial Esta seção é a última e maior etapa em nossa jornada pelo desempenho incremental da adaptação do DGEMM ao hardware subjacente do Intel Core i7 (Sandy Bridge). Cada Core i7 possui 8 núcleos, e o computador que usamos possui 2 Core i7s. Assim, temos 16 núcleos para executar o DGEMM. A Figura 6.25 mostra a versão OpenMP do DGEMM, que utiliza esses núcleos. Observe que a linha 30 é a única linha acrescentada à Figura 5.48 para fazer com que esse código seja executado em múltiplos processadores: uma pragma OpenMP que diz ao compilador para usar várias threads no loop for mais externo. Ela diz ao computador para espalhar o trabalho do loop mais externo por todas as threads.
FIGURA 6.25 Versão OpenMP do DGEMM da Figura 5.48. A linha 30 é o único código OpenMP, fazendo com que o loop for mais externo opere em paralelo. Essa linha é a única diferença da Figura 5.48.
A Figura 6.26 representa um gráfico de speed-up de multiprocessador clássico, mostrando a melhoria de desempenho contra uma única thread à medida que o número de threads aumenta. Esse gráfico facilita a visão dos desafios da expansão forte versus a expansão fraca. Quando tudo cabe na cache de dados de primeiro nível, como acontece para as matrizes 32 × 32, a inclusão de threads, na verdade, prejudica o desempenho. A versão de DGEMM para 16
threads leva quase o dobro do tempo da versão de única thread, neste caso. Ao contrário, as duas maiores matrizes obtêm um ganho de velocidade de 14× usando 16 threads, daí as duas linhas clássicas “acima e à direita” na Figura 6.26.
FIGURA 6.26 Melhorias de desempenho relativas a uma única thread à medida que o número de threads aumenta. A forma mais honesta de apresentar esses gráficos é tornar o desempenho relativo à melhor versão de um programa de único processador, como fizemos aqui. Esse gráfico é relativo ao desempenho do código na Figura 5.48 sem incluir pragmas OpenMP.
A Figura 6.27 mostra o aumento de desempenho absoluto enquanto aumentamos o número de threads de 1 para 16. O DGEMM agora opera a 174 GFLOPs para matrizes de 960 × 960. Como a nossa versão C não otimizada do DGEMM da Figura 3.21 executou esse código em apenas 0,8 GFLOPs, as otimizações nos Capítulos de 3 a 6, que ajustam o código ao hardware
subjacente, resultam em um ganho de velocidade de mais de 200 vezes!
FIGURA 6.27 Desempenho do DGEMM contra o número de threads para quatro tamanhos de matriz. A melhoria de desempenho em comparação com o código não otimizado da Figura 3.21 para a matriz de 960 × 960 com 16 threads é surpreendente: 212 vezes mais rápido!
A seguir, veremos nossos avisos das falácias e armadilhas do multiprocessamento. O cemitério da arquitetura de computador está repleto de projetos de processamento paralelo que as ignoraram.
Detalhamento Esses resultados acontecem com o modo Turbo desligado. Estamos usando um sistema de chip dual neste caso, de modo que não é surpresa que obtenhamos um ganho de velocidade total no Turbo (3,3/2.6 = 1,27) com 1 thread (apenas 1 núcleo em um dos chips) ou 2 threads (1 núcleo por chip). Ao aumentarmos o número de threads e, portanto, o número de núcleos ativos, o benefício do modo Turbo diminui, pois há menos potência a ser
gasta sobre os núcleos ativos. Para 4 threads, o ganho de velocidade médio do Turbo é 1,23, para 8 é 1,13 e para 16 é 1,11.
Detalhamento Embora o Sandy Bridge tenha suporte para duas threads de hardware por núcleo, não conseguimos mais desempenho com 32 threads. O motivo é que um único hardware AVX é compartilhado entre as duas threads multiplexadas para um núcleo, de modo que a atribuição de duas threads por núcleo na verdade prejudica o desempenho, devido ao overhead da multiplexação.
6.12. Falácias e armadilhas Por mais de uma década os analistas anunciam que a organização de um único computador alcançou seus limites e que avanços verdadeiramente significantes só podem ser feitos pela interconexão de uma multiplicidade de computadores de tal modo que permita solução cooperativa... Demonstrou-se a continuada validade do método de processador único... Gene Amdahl, “Validity of the single processor approach to achieving large scale computing capabilities”, Spring Joint Computer Conference, 1967
Os muitos ataques ao processamento paralelo revelaram inúmeras falácias e armadilhas. Veremos quatro delas aqui. Falácia: a Lei de Amdahl não se aplica aos computadores paralelos. Em 1987, o diretor de uma organização de pesquisa afirmou que a Lei de Amdahl tinha sido quebrada por uma máquina de multiprocessador. Para tentar entender a base dos relatos da mídia, vejamos a citação que nos trouxe a Lei de Amdahl [1967, p. 483]: Uma conclusão bastante óbvia que pode ser tirada nesse momento é que o esforço despendido em conseguir altas velocidades de processamento paralelo é desperdiçado se não for acompanhado de conquistas de mesmas proporções nas velocidades de processamento
sequencial. Essa afirmação ainda deve ser verdadeira; a parte ignorada do programa deve limitar o desempenho. Uma interpretação da lei leva ao seguinte princípio: partes de cada programa precisam ser sequenciais e, portanto, precisa haver um limite superior lucrativo para o número de processadores — digamos, 100. Mostrando speed-up linear com 1.000 processadores, esse princípio se torna falso e, então, a Lei de Amdahl foi quebrada. O método dos pesquisadores foi simplesmente usar a expansão fraca: em vez de ir 1.000 vezes mais rápido, eles calcularam 1.000 vezes mais trabalho em tempo comparável. Para o algoritmo deles, a parte sequencial do programa era constante, independente do tamanho da entrada, e o restante era totalmente paralelo — daí, speed-up linear com 1.000 processadores. Obviamente, a Lei de Amdahl se aplica aos processadores paralelos. O que essa pesquisa salienta é que um dos principais usos de computadores mais rápidos é executar problemas maiores. Basta ter certeza de que os usuários realmente se importam com esses problemas, em vez de ser uma justificativa para comprar um computador caro, simplesmente para manter muitos processadores ocupados. Falácia: o desempenho de pico segue o desempenho observado. A indústria de supercomputadores usou essa métrica no marketing, e a falácia é enfatizada com as máquinas paralelas. Não apenas os marqueteiros estão usando o desempenho de pico quase inatingível de um nó processador, como eles também o estão multiplicando pelo número total de processadores, considerando speed-up perfeito! A lei de Amdahl sugere como é difícil alcançar qualquer um desses picos; multiplicar os dois só multiplicará os pecados. O modelo roofline ajuda a entender melhor o desempenho de pico. Armadilha: não desenvolver o software para tirar proveito de (ou otimizar) uma arquitetura de multiprocessador. Há um longo histórico de software ficando para trás nos processadores paralelos, possivelmente porque os problemas do software são muito mais difíceis. Temos um exemplo para mostrar a sutileza dessas questões, mas existem muitos exemplos que poderíamos escolher! Um problema encontrado com frequência ocorre quando o software projetado para um processador único é adaptado a um ambiente multiprocessador. Por
exemplo, o sistema operacional da Silicon Graphics protegia originalmente a tabela de página com um único lock, supondo que a alocação de página é pouco frequente. Em um processador único, isso não representa um problema de desempenho, mas em um multiprocessador, pode se tornar um gargalo de desempenho importante para alguns programas. Considere um programa que usa um grande número de páginas que são inicializadas quando começa a ser executado, o que o UNIX faz para as páginas alocadas estaticamente. Suponha que o programa seja colocado em paralelo, de modo que múltiplos processos aloquem as páginas. Como a alocação de página requer o uso da tabela de página, que é bloqueada sempre que está em uso, até mesmo um kernel do SO que permita múltiplas threads no SO será colocado em série se todos os processos tentarem alocar suas páginas ao mesmo tempo (que é exatamente o que poderíamos esperar no momento da inicialização!). Essa serialização da tabela de página elimina o paralelismo na inicialização e tem um impacto significativo sobre o desempenho paralelo geral. Esse gargalo de desempenho persiste até mesmo para o paralelismo em nível de tarefa. Por exemplo, suponha que dividamos o programa de processamento paralelo em tarefas separadas e as executemos, uma tarefa por processador, de modo que não haja compartilhamento entre elas. É exatamente isso o que um usuário fez, pois acreditava que o problema de desempenho era devido ao compartilhamento não intencional ou interferência em sua aplicação. Infelizmente, o lock ainda coloca todas as tarefas em série — de modo que, até mesmo o desempenho da tarefa independente é fraco. Essa armadilha indica os tipos de bugs de desempenho sutis, porém significativos, que podem surgir quando o software é executado em multiprocessadores. Assim como muitos outros componentes de software essenciais, os algoritmos do OS e as estruturas de dados precisam ser repensadas em um contexto de multiprocessador. Colocar locks em partes menores da tabela de página efetivamente elimina o problema. Falácia: você pode obter um bom desempenho de vetor sem fornecer largura de banda de memória. Como vimos com o modelo roofline, a largura de banda de memória é muito importante para todas as arquiteturas. DAXPY requer 1,5 referências de memória por operação de ponto flutuante, e essa razão é comum para muitos códigos científicos. Mesmo que as operações de ponto flutuante não levassem
tempo algum, um Cray-1 não poderia aumentar o desempenho do DAXPY da sequência de vetor utilizada, pois ele era limitado pela memória. O desempenho do Cray-1 no Linpack saltou quando o compilador usou o bloqueio para alterar a computação de modo que os valores pudessem ser mantidos nos registradores de vetor. Essa técnica reduziu o número de referências à memória por FLOP e melhorou o desempenho por um fator de quase dois! Assim, a largura de banda de memória no Cray-1 tornou-se suficiente para um loop que anteriormente exigia mais largura de banda, o que é exatamente aquilo que o modelo roofline preveria.
6.13. Comentários finais Estamos dedicando todo o nosso desenvolvimento de produto futuro aos projetos multicore. Acreditamos que esse seja um ponto de inflexão importante para a indústria. [...] Essa não é uma corrida, é uma mudança de mares na computação. Paul Otellini, Presidente da Intel, Intel Developers Forum, 2004
O sonho de construir computadores apenas agregando processadores existe desde os primeiros dias da computação. No entanto, o progresso na construção e no uso de processadores paralelos eficientes tem sido lento. Essa velocidade de progresso foi limitada pelos difíceis problemas de software, bem como por um longo processo de evolução da arquitetura dos multiprocessadores para melhorar a usabilidade e a eficiência. Discutimos muitos dos problemas de software neste capítulo, incluindo a dificuldade de escrever programas que obtêm bom speedup devido à Lei de Amdahl. A grande variedade de métodos arquitetônicos diferentes e o sucesso limitado e a vida curta de muitas arquiteturas até agora se juntam às dificuldades do software. Para obter ainda mais detalhes sobre os assuntos deste capítulo, consulte Arquitetura de Computadores: Uma abordagem quantitativa, 5ª. Edição: o Capítulo 4 explica mais sobre GPUs e tem comparações entre GPUs e CPUs, e o Capítulo 6 para mais sobre WSCs. Como dissemos no Capítulo 1, apesar desse longo e sinuoso passado, a indústria da tecnologia de informação agora tem seu futuro ligado à computação paralela. Embora seja fácil apontar fatos para que esse esforço falhe como muitos no passado, existem motivos para termos esperança: ▪ Claramente, o software como um serviço (SaaS) está ganhando mais
importância, e os clusters provaram ser um modo muito bem-sucedido de oferecer tais serviços. Oferecendo redundância em um nível mais alto, incluindo centros de dados geograficamente distribuídos, esses serviços têm oferecido disponibilidade 24 × 7 × 365 para os clientes no mundo inteiro. ▪ Acreditamos que os Computadores em Escala Warehouse (WSC) estejam mudando os objetivos e os princípios do projeto de servidor, assim como as necessidades de clientes móveis estão mudando os objetivos e os princípios do projeto de microprocessador. Ambos estão revolucionando também a indústria do software. O desempenho por dólar e o desempenho por joule impulsionam tanto o hardware de clientes móveis quanto o hardware de WSC, e o paralelismo é fundamental para oferecer esses conjuntos de objetivos. ▪ Operações SIMD e de vetor são uma boa combinação para aplicações multimídia, que estão desempenhando um papel maior na era pós-PC. Elas compartilham a vantagem de serem mais fáceis para o programador do que a programação MIMD paralela clássica e de serem mais eficientes que MIMD, em termos de energia. Para entender melhor a importância de SIMD versus MIMD, a Figura 6.28 representa o número de núcleos para MIMD contra o número de operações de 32 e 64 bits por ciclo de clock no modo SIMD para computadores x86 no decorrer do tempo. Para os computadores x86, esperamos ver dois núcleos adicionais por chip a cada dois anos e a largura do SIMD dobrar aproximadamente a cada quatro anos. Dadas essas suposições, no decorrer da próxima década, o speed-up em potencial vindo do paralelismo SIMD é o dobro daquele do paralelismo MIMD. Dada a eficácia do SIMD para multimídia e sua importância crescente na era pósPC, essa ênfase pode ser apropriada. Logo, é pelo menos tão importante compreender o paralelismo SIMD quanto o paralelismo MIMD, embora esse último tenha recebido muito mais atenção.
FIGURA 6.28 Speed-up em potencial via paralelismo a partir de MIMD, SIMD e de ambos com o passar do tempo para os computadores x86. Esta figura considera que dois núcleos por chip para MIMD serão acrescentados a cada dois anos, e que o número de operações para SIMD dobrará a cada quatro anos.
▪ O uso de processamento paralelo em domínios como a computação científica e de engenharia é comum. Esse domínio de aplicação possui uma necessidade quase ilimitada de mais computação. Ele também possui muitas aplicações com uma grande quantidade de concorrência natural. Mais uma vez, os clusters dominam essa área de aplicação. Por exemplo, usando o
relatório Top 500 de 2012, os clusters representam mais de 80% dos 500 resultados Linpack mais rápidos. ▪ Todos os fabricantes de microprocessador de desktop e servidor estão construindo multiprocessadores para alcançar o desempenho mais alto, de modo que, diferente do passado, não existe um caminho fácil para o desempenho mais alto para aplicações sequenciais. Como dissemos anteriormente, os programas sequenciais agora são programas lentos. Logo, os programadores que precisam de desempenho mais alto precisam colocar seus códigos em paralelo ou escrever novos programas de processamento paralelo. ▪ No passado, os microprocessadores e os multiprocessadores estavam sujeitos a diferentes definições de sucesso. Ao expandir o desempenho do processador único, os arquitetos de microprocessador ficavam felizes se o desempenho com uma única thread subisse pela raiz quadrada da área de silício aumentada. Assim, eles ficavam felizes com uma melhoria de desempenho abaixo da linear em termos de recursos. O sucesso do multiprocessador era definido como um speed-up linear como função do número de processadores, supondo que o custo da compra ou o custo da administração de n processadores era n vezes o custo de um processador. Agora que o paralelismo está acontecendo no chip via multicore, podemos usar a métrica tradicional do microprocessador, para ter sucesso na melhoria do desempenho abaixo da linear. ▪ O sucesso da compilação em tempo de execução just-in-time e do autoajuste torna viável pensar no software adaptando-se para tirar proveito do número cada vez maior de núcleos por chip, o que oferece uma flexibilidade que não está disponível quando limitado a compiladores estáticos. ▪ Diferente do passado, o movimento do código-fonte aberto tornou-se uma parte fundamental da indústria de software. Esse movimento é uma meritocracia, em que melhores soluções de engenharia podem ganhar a fatia de desenvolvedores em relação a questões legadas. Ele também alcança a inovação, convidando a mudança no software antigo e recebendo novas linguagens e produtos de software. Essa cultura aberta poderia ser extremamente útil nessa época de mudança rápida. Para motivar os leitores a abraçar essa revolução, demonstramos o potencial do paralelismo de forma concreta para a multiplicação matricial no Intel Core i7 (Sandy Bridge) nas seções Mais Rápido dos Capítulos de 3 a 6: ▪ O paralelismo em nível de dados no Capítulo 3 melhorou o desempenho por
um fator de 3,85, executando quatro operações de ponto flutuante com 64 bits em paralelo, usando operandos de 256 bits das instruções AVX, demonstrando o valor do padrão SIMD. ▪ O paralelismo em nível de instrução no Capítulo 4 empurrou o desempenho por outro fator de 2,3, desdobrando os loops 4 vezes para oferecer, ao hardware de execução fora de ordem, mais instruções para escalonar. ▪ As otimizações de cache no Capítulo 5 melhoraram o desempenho de matrizes que não cabiam na cache de dados L1 por outro fator de 2,0 a 2,5, usando o bloqueio de cache para reduzir as falhas de cache. ▪ O paralelismo em nível de thread, neste capítulo, melhorou o desempenho de matrizes que não cabiam em uma única cache de dados L1 por outro fator de 4 a 14, utilizando todos os 16 núcleos de nossos chips multicore, demonstrando o valor do padrão MIMD. Fizemos isso acrescentando uma única linha, usando uma pragma OpenMP. O uso das ideias deste livro e o ajuste do software a esse computador acrescentou 24 linhas de código ao DGEMM. Para os tamanhos de matriz de 32 × 32, 160 × 160, 480 × 480 e 960 × 960, o speed-up de desempenho geral advindo dessas ideias, colocadas em prática nessas duas dezenas de linhas de código, tem um fator de 8, 39, 129 e 212, respectivamente! Essa revolução paralela na interface de hardware/software talvez seja o maior desafio que esse campo encarou nos últimos 60 anos. Você também pode pensar nela como a maior das oportunidades, como demonstram nossas seções Mais Rápido. Essa revolução oferecerá muitas novas oportunidades de pesquisa e negócios dentro e fora do campo de TI, e as empresas que dominam a era do multicore podem não ser as mesmas que dominaram a era do processador único. Depois de compreender as tendências fundamentais do hardware e aprender a adaptar o software a elas, talvez você será um dos inovadores que aproveitará as oportunidades que certamente aparecerão nos tempos de incerteza por vir. Esperamos que um dia nos beneficiemos de suas invenções!
6.14. Exercícios 6.1 Primeiro, escreva uma lista das atividades diárias que você realiza normalmente em um fim de semana. Por exemplo, você poderia levantar da cama, tomar um banho, se vestir, tomar o café, secar seu cabelo, escovar os dentes etc. Lembre-se de distribuir sua lista de modo que tenha um mínimo de dez atividades.
6.1.1 [5] Agora considere qual dessas atividades já está explorando alguma forma de paralelismo (por exemplo, escovar vários dentes ao mesmo tempo em vez de um de cada vez, carregar um livro de cada vez para a escola em vez de colocar todos eles na sua mochila, e depois carregá-los “em paralelo”). Para cada uma de suas atividades, discuta se elas já estão sendo executadas em paralelo, mas se não, por que não estão. 6.1.2 [5] Em seguida, considere quais das atividades poderiam ser executadas simultaneamente (por exemplo, tomar café e escutar as notícias). Para cada uma das suas atividades, descreva qual outra atividade poderia ser emparelhada com essa atividade. 6.1.3 [5] Para o Exercício 6.1.2, o que poderíamos mudar sobre os sistemas atuais (por exemplo, banhos, roupas, TVs, carros) de modo que pudéssemos realizar mais tarefas em paralelo? 6.1.4 [5] Estime quanto tempo a menos seria necessário para executar essas atividades se você tentasse executar o máximo de tarefas em paralelo possível. 6.2 Você está tentando preparar três tortas de amora. Os ingredientes são os seguintes: 1 xícara de manteiga 1 xícara de açúcar 4 ovos grandes 1 colher de chá de extrato de baunilha 1/2 colher de chá de sal 1/4 colher de chá de noz moscada 1 1/2 xícaras de farinha de trigo 1 xícara de amoras A receita para uma única torta é a seguinte: Passo 1: pré-aqueça o forno a 160 °C. Unte e polvilhe farinha na forma. Passo 2: em uma bacia grande, bata com a batedeira a manteiga e o açúcar em velocidade média até que a massa fique leve e macia. Acrescente ovos, baunilha, sal e noz moscada. Bata até que tudo fique totalmente misturado. Reduza a velocidade da batedeira e acrescente farinha de trigo, 1/2 xícara por vez, batendo até ficar bem misturado. Passo 3: inclua as amoras aos poucos. Espalhe uniformemente na forma da torta. Leve ao forno 60 minutos. 6.2.1 [5] Sua tarefa é cozinhar três tortas da forma mais eficiente possível. Supondo que você só tenha um forno com tamanho suficiente para
conter uma torta, uma bacia grande, uma forma de torta e uma batedeira, prepare um plano para fazer as três tortas o mais rapidamente possível. Identifique os gargalos para completar essa tarefa. 6.2.2 [5] Suponha agora que você tenha três bacias, três formas de torta e três batedeiras. O quanto o processo fica mais rápido, agora que você tem esses recursos adicionais? 6.2.3 [5] Agora suponha que você tem dois amigos que o ajudarão a cozinhar, e que você tem um forno grande, que possa acomodar todas as três tortas. Como isso mudará o plano que você preparou no Exercício 6.2.1? 6.2.4 [5] Compare a tarefa de preparação da torta com o cálculo de três iterações de um loop em um computador paralelo. Identifique o paralelismo em nível de dados e o paralelismo em nível de tarefa no loop de preparação da torta. 6.3 Muitas aplicações de computador envolvem a pesquisa por um conjunto de dados e a classificação dos dados. Diversos algoritmos eficientes de busca e classificação foram criados para reduzir o tempo de execução dessas tarefas tediosas. Neste problema, vamos considerar como é melhor colocar essas tarefas em paralelo. 6.3.1 [10] Considere o seguinte algoritmo de busca binária (um algoritmo clássico do tipo dividir e conquistar) que procura um valor X em um array de N elementos A e retorna o índice da entrada correspondente:
Suponha que você tenha Y núcleos em um processador multicore para executar BinarySearch. Supondo que Y seja muito menor que N, expresse o fator de speed-up que você poderia esperar obter para os valores de Y e N. Desenhe isso em um gráfico. 6.3.2 [5] Em seguida, suponha que Y seja igual a N. Como isso afetaria suas conclusões na sua resposta anterior? Se você estivesse encarregado de obter o melhor fator de speed-up possível (ou seja, expansão forte), explique como poderia mudar esse código para obter isso. 6.4 Considere o seguinte trecho de código em C:
O código MIPS correspondente a esse fragmento é:
As instruções têm as seguintes latências associadas (em ciclos): add.d
l.d
s.d addiu
4
6
1
2
6.4.1 [10] Quantos ciclos são necessários para que todas as instruções em uma única iteração do loop anterior sejam executadas? 6.4.2 [10] Quando uma instrução em uma iteração posterior de um loop depende do valor de dados produzido em uma iteração anterior do mesmo loop, dizemos que existe uma dependência carregada pelo loop entre as iterações do loop. Identifique as dependências carregadas pelo loop no código anterior. Identifique a variável de programa dependente e os registradores em nível de assembly. Você pode ignorar a variável de indução de loop j. 6.4.3 [10] O desdobramento de loop foi descrito no Capítulo 4. Aplique
o desdobramento de loop a esse loop e depois considere a execução desse código em um sistema de passagem de mensagem com memória distribuída com 2 nós. Suponha que usaremos a passagem de mensagem conforme descrito na Seção 6.7, na qual apresentamos uma nova operação send(x,y) que envia ao nó x o valor y, e uma operação receive( ) que espera pelo valor sendo enviado a ele. Suponha que as operações send gastem um ciclo para despachar (ou seja, outras instruções no mesmo nó podem prosseguir para o próximo ciclo), mas gastem 10 ciclos para serem recebidas no nó receptor. Operações receive provocam stall da execução no nó em que são executadas até que recebam uma mensagem. Produza um schedule para os dois nós; considere um fator de desdobramento de 4 para o corpo do loop (ou seja, o corpo do loop aparecerá quatro vezes). Calcule o número de ciclos necessários para que o loop seja executado no sistema de passagem de mensagens. 6.4.4 [10] A latência da rede de interconexão desempenha um papel importante na eficiência dos sistemas de passagem de mensagens. Que velocidade a interconexão precisa ter, a fim de obter qualquer speed-up com o uso do sistema distribuído descrito no Exercício 6.4.3? 6.5 Considere o seguinte algoritmo mergesort recursivo (outro algoritmo clássico para dividir e conquistar). Mergesort foi descrito inicialmente por John von Neumann em 1945. A ideia básica é dividir uma lista não classificada x de m elementos em duas sublistas de aproximadamente metade do tamanho da lista original. Repita essa operação em cada sublista e continue até que tenhamos listas de tamanho 1. Depois, começando com sublistas de tamanho 1, faça o “merge” das duas sublistas em uma única lista classificada.
A etapa do merge é executada pelo seguinte código:
6.5.1 [10] Suponha que você tenha Y núcleos em um processador multicore para executar o MergeSort. Supondo que Y seja muito menor que length(m), expresse o fator de speed-up que você poderia esperar obter para os valores de Y e length(m). Desenhe isso em um gráfico. 6.5.2 [10] Em seguida, considere que Y é igual a length(m). Como isso afetaria suas conclusões na sua resposta anterior? Se você estivesse encarregado de obter o melhor fator de speed-up possível (ou seja, expansão forte), explique como poderia mudar esse código para obtê-lo. 6.6 A multiplicação de matriz desempenha um papel importante em diversas aplicações. Duas matrizes só podem ser multiplicadas se o número de colunas da primeira matriz for igual ao número de linhas na segunda. Vamos supor que tenhamos uma matriz m × n (A) e queiramos multiplicá-la por uma matriz n × p (B). Podemos expressar seu produto como uma matriz m × p indicada por AB (ou A · B). Se atribuirmos C = AB, e ci,j indicar a entrada em C na posição (i, j), então para cada elemento i e j com 1 ≤ i ≤ m e 1 ≤ j ≤ p. Agora, queremos ver se podemos fazer o cálculo de C em paralelo. Suponha que as matrizes estejam dispostas na memória sequencialmente da seguinte forma: a1,1, a2,1, a3,1, a4,1, …, etc. 6.6.1 [10] Suponha que iremos calcular C em uma máquina de memória compartilhada de núcleo único e uma máquina com memória compartilhada
de 4 núcleos. Calcule o speed-up que esperaríamos obter em uma máquina de 4 núcleos, ignorando quaisquer problemas de memória. 6.6.2 [10] Repita o Exercício 6.6.1, supondo que as atualizações em C incorrem em uma falha de cache, devido ao compartilhamento falso quando os elementos consecutivos que estão em sequência (ou seja, índice i) são atualizados. 6.6.3 [10] Como você consertaria o problema de compartilhamento falso que pode ocorrer? 6.7 Considere as seguintes partes de dois programas diferentes rodando ao mesmo tempo com quatro processadores em um processador multicore simétrico (SMP). Suponha que, antes que esse código seja executado, tanto x quanto y sejam 0.
6.7.1 [10] Quais são todos os valores resultantes possíveis de w, x, y e z? Para cada resultado possível, explique como poderíamos chegar a esses resultados. Você precisará examinar todas as intercalações possíveis das instruções. 6.7.2 [5] Como você poderia tornar a execução mais determinística, de modo que somente um conjunto de valores seja possível? 6.8 O problema do jantar dos filósofos é um problema clássico de
sincronização e concorrência. O problema geral é enunciado como filósofos sentados em volta de uma mesa redonda fazendo uma de duas coisas: comendo ou pensando. Quando eles estão comendo, não estão pensando, e quando estão pensando, não estão comendo. Há uma tigela de macarrão no centro. Um garfo é colocado entre cada filósofo. O resultado é que cada filósofo tem um garfo à sua esquerda e um garfo à sua direita. Devido à forma como se come macarrão, o filósofo precisa de dois garfos para comer, e só pode usar os garfos do seu lado esquerdo e direito. Os filósofos não conversam entre si. 6.8.1 [10] Descreva o cenário em que nenhum dos filósofos consegue comer (ou seja, inanição). Qual é a sequência de eventos que leva a esse problema? 6.8.2 [10] Descreva como podemos solucionar esse problema introduzindo o conceito de uma prioridade. Mas podemos garantir que trataremos de todos os filósofos de forma justa? Explique. Agora, suponha que contratemos um garçom encarregado de atribuir garfos aos filósofos. Ninguém pode pegar um garfo até que o garçom lhe diga que pode. O garçom tem conhecimento global de todos os garfos. Além disso, se impusermos a diretriz de que os filósofos sempre solicitarão o seu garfo da esquerda antes de apanhar seu garfo da direita, então podemos garantir que o impasse (deadlock) será evitado. 6.8.3 [10] Podemos implementar as solicitações ao garçom como uma fila de solicitações ou como uma retentativa periódica de uma solicitação. Com uma fila, as solicitações são tratadas na ordem em que são recebidas. O problema com o uso da fila é que podemos nem sempre ser capazes de atender ao filósofo cuja solicitação está no início da fila (devido à indisponibilidade de recursos). Descreva um cenário com cinco filósofos, em que uma fila é fornecida, mas o serviço não é concedido mesmo que haja garfos disponíveis para outro filósofo (cuja solicitação está mais profunda na fila) utilizar. 6.8.4 [10] Se implementarmos solicitações ao garçom repetindo periodicamente nossa solicitação até que os recursos estejam disponíveis, isso solucionará o problema descrito no Exercício 6.8.3? Explique. 6.9 Considere as três organizações de CPU a seguir: CPU SS: Um microprocessador superescalar de dois núcleos que oferece capacidades de despacho fora de ordem em duas unidades funcionais (FUs). Somente uma única thread pode ser executada em cada núcleo de cada vez.
CPU MT: Um processador multithreaded fine-grained que permite que instruções de duas threads sejam executadas simultaneamente (ou seja, existem duas unidades funcionais), embora somente instruções de uma única thread possam ser emitidas em cada ciclo. CPU SMT: Um processador SMT que permite que instruções de duas threads sejam executadas simultaneamente (ou seja, existem duas unidades funcionais), e as instruções de qualquer uma ou ambas as threads podem ser emitidas para executar em qualquer ciclo. Suponha que tenhamos duas threads, X e Y, para executar nessas CPUs, o que inclui as seguintes operações: Thread X
Thread Y
A1 – leva 3 ciclos para executar
B1 – leva 2 ciclos para executar
A2 – sem dependências
B2 – conflitos para uma unidade funcional com B1
A3 – conflitos para uma unidade funcional com A1 B3 – depende do resultado de B2 A4 – depende do resultado de A3
B4 – sem dependências e leva 2 ciclos para executar
Suponha que todas as instruções utilizem um único ciclo para serem executadas, a menos que sejam observados de outra forma ou que encontrem um hazard. 6.9.1 [10] < §6.4> Suponha que você tenha uma CPU SS. Quantos ciclos são necessários para executar essas duas threads? Quantos slots de despacho são desperdiçados devido a hazards? 6.9.2 [10] < §6.4> Agora, suponha que você tenha duas CPUs SS. Quantos ciclos são necessários para executar essas duas threads? Quantos slots de despacho são desperdiçados devido a hazards? 6.9.3 [10] < §6.4> Suponha que você tenha uma CPU MT. Quantos ciclos são necessários para executar essas duas threads? Quantos slots de despacho são desperdiçados devido a hazards? 6.10 O software de virtualização está sendo agressivamente implantado para reduzir os custos de gerenciamento dos servidores de alto desempenho de hoje. Empresas como VMWare, Microsoft e IBM desenvolveram diversos produtos de virtualização. O conceito geral, descrito no Capítulo 5, é que uma camada hipervisora pode ser introduzida entre o hardware e o sistema operacional para permitir que vários sistemas operacionais compartilhem o mesmo hardware físico. A camada hipervisora é então responsável por alocar recursos de CPU e memória, além de tratar os serviços normalmente tratados pelo sistema operacional (por exemplo, E/S).
A virtualização oferece uma visão abstrata do hardware subjacente ao sistema operacional host e ao software de aplicação. Isso exigirá que repensemos como os sistemas multicore e multiprocessador serão projetados no futuro para dar suporte ao compartilhamento das CPUs e memórias por diversos sistemas operacionais simultaneamente. 6.10.1 [30] Selecione dois hipervisores no mercado hoje e compare como eles virtualizam e gerenciam o hardware subjacente (CPUs e memória). 6.10.2 [15] Discuta quais mudanças podem ser necessárias nas plataformas de CPU multicore do futuro, a fim de que sejam mais coerentes com as demandas de recursos impostas sobre esses sistemas. Por exemplo, o multithreading pode desempenhar um papel eficaz para reduzir a competição por recursos de computação? 6.11 Gostaríamos de executar o loop a seguir da forma mais eficiente possível. Temos duas máquinas diferentes, uma máquina MIMD e uma máquina SIMD.
6.11.1 [10] Para uma máquina MIMD com quatro CPUs, mostre a sequência de instruções MIPS que você executaria em cada CPU. Qual é o speed-up para essa máquina MIMD? 6.11.2 [20] Para uma máquina SIMD de largura 8 (ou seja, oito unidades funcionais SIMD paralelas), escreva um programa em assembly usando suas próprias extensões SIMD ao MIPS para executar o loop. Compare o número de instruções executadas na máquina SIMD com a máquina MIMD. 6.12 Um array sistólico é um exemplo de uma máquina MISD. Um array sistólico é uma rede de pipeline ou wavefront de elementos de processamento de dados. Cada um desses elementos não precisa de um contador de programa, pois a execução é disparada pela chegada de dados. Os arrays sistólicos com clock são calculados em lock-step, com cada
processador realizando fases alternadas de cálculo e comunicação. 6.12.1 [10] Considere as implementações propostas de um array sistólico (você pode encontrá-las na Internet ou em publicações técnicas). Depois, tente programar o loop fornecido no Exercício 6.11 usando esse modelo MISD. Discuta quaisquer dificuldades que você encontrar. 6.12.2 [10] Discuta as semelhanças e diferenças entre uma máquina MISD e SIMD. Responda a essa pergunta em termos de paralelismo em nível de dados. 6.13 Suponha que queiramos executar o loop DAXPY mostrado na Seção 6.3 em assembly MIPS na GPU NVIDIA 8800 GTX descrita neste capítulo. Nesse problema, vamos supor que todas as operações matemáticas sejam realizadas em números de ponto flutuante com precisão simples (vamos mudar o nome do loop para SAXPY). Suponha que as instruções utilizem o seguinte número de ciclos para serem executadas. Loads Stores Add.S Mult.S 5
2
3
4
6.13.1 [20] Descreva como você construirá warps para o loop SAXPY explorar os oito núcleos fornecidos em um único multiprocessador. 6.14 Faça o download do CUDA Toolkit e SDK em http://www.nvidia.com/object/cuda_get.html. Lembre-se de usar a versão “emurelease” (Emulation Mode) do código (você não precisará do hardware NVIDIA real para esse trabalho). Crie os programas de exemplo fornecidos no SDK e confirme se eles rodarão no emulador. 6.14.1 [90] Usando o “template” de exemplo de SDK como ponto de partida, escreva um programa CUDA para realizar o seguinte vetor de operações: a) a – b (subtração vetor-vetor) b) a · b (produto pontual de vetor) O produto pontual de dois vetores a = [a1, a2,…, an] e b = [b1, b2,…, bn] é definido como:
Submeta o código para cada programa que demonstra cada operação e verifique a exatidão dos resultados. 6.14.2 [90] Se você tiver hardware GPU disponível, complete uma análise de desempenho do seu programa, examinando o tempo de computação para a GPU e uma versão de CPU do seu programa para uma faixa de tamanhos de vetor. Explique quaisquer resultados que você encontrar. 6.15 A AMD recentemente anunciou que integrará uma unidade de processamento gráfica com seus núcleos x86 em um único pacote, embora com diferentes clocks para cada um dos núcleos. Este é um exemplo de um sistema multiprocessador heterogêneo que esperamos ver produzido comercialmente no futuro próximo. Um dos principais pontos de projeto será permitir a comunicação de dados rápida entre a CPU e a GPU. Atualmente a comunicação deve ser realizada entre chips de CPU e GPU discretos. Mas isso está mudando na arquitetura Fusion da AMD. Atualmente, o plano é usar múltiplos canais PCI express (pelo menos, 16) para facilitar a intercomunicação. A Intel também está saltando para essa arena com seu chip Larrabee. A Intel está considerando o uso de sua tecnologia de interconexão QuickPath. 6.15.1 [25] Compare a largura de banda e latência associadas a essas duas tecnologias de interconexão. 6.16 Consulte a Figura 6.14b, que mostra uma topologia de interconexão de cubo n de ordem 3, que interconecta oito nós. Um recurso atraente de uma topologia de rede de interconexão de cubo n é sua capacidade de sustentar links partidos e ainda oferecer conectividade. 6.16.1 [10] Desenvolva uma equação que calcule quantos links no cubo n (onde n é a ordem do cubo) podem falhar e ainda podemos garantir que um link não partido existirá para conectar qualquer nó no cubo n. 6.16.2 [10] Compare a resiliência à falha do cubo n com uma rede de interconexão totalmente conectada. Desenhe uma comparação da confiabilidade como uma função do número de links que podem falhar para as duas topologias. 6.17 O benchmarking é um campo de estudo que envolve identificar cargas de trabalho representativas para rodar em plataformas de computação específicas a fim de poder comparar objetivamente o desempenho de um sistema com outro. Neste exercício, vamos comparar duas classes de benchmarks: o benchmark Whetstone CPU e o pacote de benchmark
PARSEC. Selecione um programa do PARSEC. Todos os programas deverão estar disponíveis gratuitamente na Internet. Considere a execução de múltiplas cópias do Whetstone contra a execução do benchmark PARSEC em qualquer um dos sistemas descritos na Seção 6.10. 6.17.1 [60] O que é inerentemente diferente entre essas duas classes de carga de trabalho quando executadas nesses sistemas multicore? 6.17.2 [60] Em termos do modelo roofline, que dependência terão os resultados que você obtiver ao executar esses benchmarks na quantidade de compartilhamento e sincronização presente na carga de trabalho utilizada? 6.18 Ao realizar cálculos sobre matrizes esparsas, a latência na hierarquia de memória torna-se um fator muito importante. As matrizes esparsas não possuem a localidade espacial no fluxo de dados, normalmente encontrada nas operações de matriz. Como resultado, novas representações de matriz foram propostas. Uma das representações de matriz esparsa mais antigas é o Yale Sparse Matrix Format. Ele armazena uma matriz esparsa inicial m × n, M, em formato de linha usando três arrays unidimensionais. Suponha que R indique o número de entradas diferentes de zero em M. Construímos um array A de tamanho R que contém todas as entradas diferentes de zero de M (na ordem da esquerda para a direita e de cima para baixo). Também construímos um segundo array IA de tamanho m + 1 (ou seja, uma entrada por linha, mais um). IA(i) contém o índice de A do primeiro elemento diferente de zero da linha i. A linha i da matriz original se estende de A(IA(i)) até A(IA(i + 1) − 1). O terceiro array, JA, contém o índice de coluna de cada elemento de A, de modo que também tem tamanho R. 6.18.1 [15] Considere a matriz esparsa X a seguir e escreva o código C que armazenaria esse código no Yale Sparse Matrix Format.
6.18.2 [10] Em termos do espaço de armazenamento, supondo que cada elemento na matriz X tenha formato de ponto flutuante com precisão simples, calcule a quantidade de armazenamento usada para armazenar a matriz acima no Yale Sparse Matrix Format. 6.18.3 [15] Realize a multiplicação de matriz da Matriz X pela Matriz Y mostrada a seguir.
Coloque esse cálculo em um loop e meça o tempo de sua execução. Não se esqueça de aumentar o número de vezes que esse loop é executado para obter uma boa resolução na sua medição do tempo. Compare o tempo de execução do uso de uma representação simples da matriz e do Yale Sparse Matrix Format. 6.18.4 [15] Você consegue achar uma representação de matriz esparsa mais eficiente (em termos de overhead de espaço e computacional)? 6.19 Nos sistemas do futuro, esperamos ver plataformas de computação heterogêneas construídas a partir de CPUs heterogêneas. Começamos a ver algumas aparecendo no mercado de processamento embutido nos sistemas que contêm DSPs de ponto flutuante e CPUs de microcontrolador em um pacote de módulo multichip. Suponha que você tenha três classes de CPU:
CPU A — Uma CPU multicore de velocidade moderada (com uma unidade de ponto flutuante) que pode executar múltiplas instruções por ciclo. CPU B — Uma CPU inteira de único core rápida (ou seja, sem unidade de ponto flutuante) que pode executar uma única instrução por ciclo. CPU C — Uma CPU de vetor lenta (com capacidade de ponto flutuante) que pode executar múltiplas cópias da mesma instrução por ciclo. Suponha que nossos processadores executem nas seguintes frequências: CPU A
CPU B
CPU C
1 GHz
3 GHz
250 MHz
A CPU A pode executar 2 instruções por ciclo, a CPU B pode executar 1 instrução por ciclo, e a CPU C pode executar 8 instruções (embora a mesma instrução) por ciclo. Suponha que todas as operações possam concluir sua execução em um único ciclo de latência sem quaisquer hazards. Todas as três CPUs possuem a capacidade de realizar aritmética com inteiros, embora a CPU B não possa realizar aritmética de ponto flutuante. As CPUs A e B têm um conjunto de instruções semelhante a um processador MIPS. A CPU C só pode realizar operações de soma e subtração de ponto flutuante, assim como loads e stores de memória. Suponha que todas as CPUs tenham acesso à memória compartilhada e que a sincronização tenha custo zero. A tarefa em mãos é comparar duas matrizes X e Y, que contêm cada uma 1024 × 1024 elementos de ponto flutuante. A saída deverá ser uma contagem dos índices numéricos em que o valor em X foi maior que o valor em Y. 6.19.1 [10] Descreva como você particionaria o problema nas três CPUs diferentes para obter o melhor desempenho. 6.19.2 [10] Que tipo de instrução você acrescentaria à CPU de vetor C para obter o melhor desempenho? 6.20 Suponha que um sistema de computador quad-core possa processar consultas de banco de dados em uma taxa de estado constante de solicitações por segundo. Suponha também que cada instrução leve, na média, uma quantidade de tempo fixa para ser processada. A tabela a seguir mostra pares de latência de transação e taxa de processamento. Latência de transação média Taxa de processamento de transação máxima 1 ms
5000/seg
2 ms
5000/seg
1 ms
10.000/seg
2 ms
10.000/seg
Para cada um dos pares na tabela, responda às seguintes perguntas: 6.20.1 [10] Na média, quantas solicitações estão sendo processadas em determinado instante? 6.20.2 [10] Se você passasse para um sistema de 8 cores, na forma ideal, o que aconteceria com a vazão do sistema (ou seja, quantas transações/segundo o computador processará)? 6.20.3 [10] Discuta por que raramente obtemos esse tipo de speed-up simplesmente aumentando o número de cores.
Respostas das Seções “Verifique você mesmo” §6.1, página 440: Falso. O paralelismo em nível de tarefa pode ajudar as aplicações sequenciais e as aplicações sequenciais podem ser criadas para executar em hardware paralelo, embora isso seja mais difícil. §6.2, página 445: Falso. A expansão fraca pode compensar uma parte serial do programa que, de outra forma, limitaria a expansão, mas não tanto para a expansão forte. §6.3, página 450: Verdadeiro, mas não existem recursos de vetor úteis, como gather-scatter e registradores de comprimento de vetor, que melhoram a eficiência das arquiteturas de vetor. (Como um detalhe mencionado nesta seção, as extensões SIMD do AVX2 oferecem loads indexados por meio de uma operação gather, mas não scatter para stores indexados. A geração Haswell do microprocessador x86 é a primeira a dar suporte para AVX2.) §6.4, página 454: 1. Verdadeiro. 2. Verdadeiro. §6.5, página 457: 1. Falso. Como o endereço compartilhado é um endereço físico, tarefas múltiplas, cada em seus próprios espaços de endereços virtuais, podem ser executados muito bem em um multiprocessador de memória compartilhada. §6.6, página 463: Falso. Chips de DRAM gráficos são premiados por sua maior largura de banda. §6.7, página 468: 1. Falso. Enviar e receber uma mensagem é uma sincronização implícita, além de um meio de compartilhar dados. 2. Verdadeiro. §6.8, página 471: Verdadeiro §6.9, página 480: Verdadeiro. Provavelmente precisamos de inovação em todos os níveis da pilha de hardware e software para que a computação paralela tenha sucesso.
paralela tenha sucesso.
APÊNDICE A
Assemblers, Link-editores e o Simulador SPIM James R. Larus Microsoft Research, Microsoft
O receio do insulto sério não pode justificar sozinho a supressão da livre expressão. Louis Brandeis, Whitney v. California, 1927
A.1 Introdução A.2 Assemblers A.3 Link-editores A.4 Carregando A.5 Uso da memória A.6 Convenção para chamadas de procedimento A.7 Exceções e interrupções A.8 Entrada e saída A.9 SPIM A.10 Assembly do MIPS R2000 A.11 Comentários finais A.12 Exercícios
A.1. Introdução Codificar instruções como números binários é algo natural e eficiente para os computadores. Os humanos, porém, têm muita dificuldade para entender e manipular esses números. As pessoas leem e escrevem símbolos (palavras) muito melhor do que longas sequências de dígitos. O Capítulo 2 mostrou que não precisamos escolher entre números e palavras, pois as instruções do computador podem ser representadas de muitas maneiras. Os humanos podem escrever e ler símbolos, enquanto os computadores podem executar os números binários equivalentes. Este apêndice descreve o processo pelo qual um programa legível ao ser humano é traduzido para um formato que um computador pode executar, oferece algumas dicas sobre a escrita de programas em assembly e explica como executar esses programas no SPIM, um simulador que executa programas MIPS. Linguagem assembly é a representação simbólica da codificação binária — linguagem de máquina — de um computador. O assembly é mais legível do que a linguagem de máquina porque utiliza símbolos no lugar de bits. Os símbolos no assembly nomeiam padrões de bits que ocorrem comumente, como opcodes (códigos de operação) e especificadores de registradores, de modo que as pessoas possam ler e lembrar-se deles. Além disso, o assembly permite que os programadores utilizem rótulos para identificar e nomear palavras particulares da memória que mantêm instruções ou dados.
linguagem de máquina A representação binária utilizada para a comunicação dentro de um sistema computacional. Uma ferramenta chamada assembler (montador) traduz do assembly para instruções binárias. Os assemblers oferecem uma representação mais amigável do que os 0s e 1s de um computador, o que simplifica a escrita e a leitura de programas. Nomes simbólicos para operações e locais são uma faceta dessa representação. Outra faceta são as facilidades de programação que aumentam a clareza de um programa. Por exemplo, as macros, discutidas na Seção A.2, permitem que um programador estenda o assembly, definindo novas operações.
assembler (montador) Um programa que traduz uma versão simbólica de uma instrução para a versão binária.
macro Uma facilidade de combinação e substituição de padrões que oferece um mecanismo simples para nomear uma sequência de instruções utilizada com frequência. Um assembler lê um único arquivo-fonte em assembly e produz um arquivoobjeto com instruções de máquina e informações de trabalho que ajudam a combinar vários arquivos-objeto em um programa. A Figura A.1.1 ilustra como um programa é montado. A maioria dos programas consiste em vários arquivos — também chamados de módulos — que são escritos, compilados e montados de forma independente. Um programa também pode usar rotinas pré-escritas fornecidas em uma biblioteca de programa. Um módulo normalmente contém referências a sub-rotinas e dados definidos em outros módulos e em bibliotecas. O código em um módulo não pode ser executado quando contém referências não resolvidas para rótulos em outros arquivos-objeto ou bibliotecas. Outra ferramenta, chamada link-editor, combina uma coleção de arquivos-objeto e biblioteca em um arquivo executável, que um computador pode executar.
FIGURA A.1.1 O processo que produz um arquivo executável. Um assembler traduz um arquivo em assembly para um arquivoobjeto, que é link-editado a outros arquivos e bibliotecas para gerar um arquivo executável.
referência não resolvida Uma referência que exige mais informações de um arquivo externo para estar completa.
link-editor Também chamado linker. Um programa de sistema que combina programas em linguagem de máquina montados independentemente e resolve todos os rótulos indefinidos em um arquivo executável. Para ver a vantagem do assembly, considere a sequência de figuras mostrada a seguir, todas contendo uma pequena sub-rotina que calcula e exibe a soma dos quadrados dos inteiros de 0 a 100. A Figura A.1.2 mostra a linguagem de máquina que um computador MIPS executa. Com esforço considerável, você poderia usar as tabelas de formato de opcode e instrução do Capítulo 2 para traduzir as instruções em um programa simbólico, semelhante à Figura A.1.3. Essa forma de rotina é muito mais fácil de ler, pois as operações e os operandos estão escritos com símbolos, em vez de padrões de bits. No entanto, esse assembly ainda é difícil de acompanhar, pois os locais da memória são indicados por seus endereços, e não por um rótulo simbólico.
FIGURA A.1.2 Código em linguagem de máquina MIPS para uma rotina que calcula e exibe a soma dos quadrados dos inteiros, entre 0 a 100.
FIGURA A.1.3 A mesma rotina da Figura A.1.2 escrita em linguagem assembly. Entretanto, o código para a rotina não rotula registradores ou locais de memória, nem inclui comentários.
A Figura A.1.4 mostra o assembly que rotula endereços de memória com nomes simbólicos ou mnemônicos. A maior parte dos programadores prefere ler e escrever dessa forma. Os nomes que começam com um ponto, por exemplo, .data e .globl, são diretivas do assembler, que dizem ao assembler como traduzir um programa, mas não produzem instruções de máquina. Nomes seguidos por um sinal de dois-pontos, como str: ou main:, são rótulos que dão nome ao próximo local da memória. Esse programa é tão legível quanto a maioria dos programas em assembly (exceto por uma óbvia falta de comentários), mas ainda é difícil de acompanhar, pois muitas operações simples são exigidas para realizar tarefas simples e porque a falta de construções de fluxo de controle do assembly oferece poucos palpites sobre a operação do programa.
FIGURA A.1.4 A mesma rotina da Figura A.1.2 escrita em assembly com rótulos, mas sem comentários. Os comandos que começam com pontos são diretivas do assembler (ver páginas A-47 a A-49). .text indica que as linhas seguintes contêm instruções. .data indica que elas contêm dados. .align n indica que os itens nas linhas seguintes devem ser alinhados em um limite de 2n bytes. Logo, .align 2 significa que o próximo item deverá estar em um limite de palavra. .globl main declara que main é um símbolo global, que deverá ser visível
ao código armazenado em outros arquivos. Finalmente, .asciiz armazena na memória uma string terminada em nulo.
diretiva do assembler Uma operação que diz ao assembler como traduzir um programa, mas não produz instruções de máquina; sempre começa com um ponto. Em contraste, a rotina em C na Figura A.1.5 é mais curta e mais clara, pois as variáveis possuem nomes simbólicos e o loop é explícito, em vez de construído com desvios. Na verdade, a rotina em C é a única que escrevemos. As outras formas do programa foram produzidas por um compilador C e um assembler.
FIGURA A.1.5 A rotina escrita na linguagem de programação C.
Em geral, o assembly desempenha duas funções (ver Figura A.1.6). A primeira é como uma linguagem de saída dos compiladores. Um compilador traduz um programa escrito em uma linguagem de alto nível (como C ou Pascal) para um programa equivalente em linguagem de máquina ou assembly. A linguagem de alto nível é chamada de linguagem-fonte (ou código-fonte), e a saída do compilador é sua linguagem-objeto (ou código-objeto).
FIGURA A.1.6 O assembly é escrito por um programador ou é a saída de um compilador.
linguagem-fonte (ou código-fonte) A linguagem de alto nível em que um programa é escrito originalmente. A outra função do assembly é como uma linguagem para a escrita de programas. Essa função costumava ser a dominante. Hoje, porém, devido a memórias maiores e compiladores melhores, a maioria dos programadores utiliza uma linguagem de alto nível e raramente ou nunca vê as instruções que um computador executa. Apesar disso, o assembly ainda é importante para a escrita de programas em que a velocidade ou o tamanho são fundamentais, ou para explorar recursos do hardware que não possuem correspondentes nas linguagens de alto nível. Embora este apêndice dê destaque ao assembly do MIPS, a programação assembly na maioria das outras máquinas é muito semelhante. As outras instruções e os modos de endereçamento nas máquinas CISC, como o VAX, podem tornar os programas assembly mais curtos, mas não mudam o processo de montagem de um programa, nem oferecem ao assembly as vantagens das linguagens de alto nível, como verificação de tipos e fluxo de controle estruturado.
Quando usar a linguagem assembly O motivo principal para programar em assembly, em vez de outra linguagem de alto nível, é que a velocidade ou o tamanho de um programa têm extrema importância. Por exemplo, imagine um computador que controla um mecanismo qualquer, como os freios de um carro. Um computador incorporado em outro dispositivo, como um carro, é chamado de computador embutido. Esse tipo de computador precisa responder rápida e previsivelmente aos eventos no mundo
exterior. Como um compilador introduz incerteza sobre o custo de tempo das operações, os programadores podem achar difícil garantir que um programa em linguagem de alto nível responderá dentro de um intervalo de tempo definido — digamos, 1 milissegundo após um sensor detectar que um pneu está derrapando. Um programador assembly, por outro lado, possui mais controle sobre as instruções executadas. Além disso, em aplicações embutidas, reduzir o tamanho de um programa, de modo que caiba em menos chips de memória, reduz o custo do computador embutido. Uma técnica híbrida, em que a maior parte de um programa é escrita em uma linguagem de alto nível e seções fundamentais são escritas em assembly, aproveita os pontos fortes das duas linguagens. Os programas normalmente gastam a maior parte do seu tempo executando uma pequena fração do códigofonte do programa. Essa observação é exatamente o princípio da localidade em que as caches se baseiam (veja Seção 5.1, no Capítulo 5). O perfil do programa mede onde um programa gasta seu tempo e pode localizar as partes de tempo crítico de um programa. Em muitos casos, essa parte do programa pode se tornar mais rápida com melhores estruturas de dados ou algoritmos. No entanto, às vezes, melhorias de desempenho significativas só são obtidas com a recodificação de uma parte crítica de um programa em linguagem assembly. Essa melhoria não é necessariamente uma indicação de que o compilador da linguagem de alto nível falhou. Os compiladores costumam ser melhores do que os programadores na produção uniforme de código de máquina de alta qualidade por um programa inteiro. Entretanto, os programadores entendem os algoritmos e o comportamento de um programa em um nível mais profundo do que um compilador e podem investir esforço e engenhosidade consideráveis melhorando pequenas seções do programa. Em particular, os programadores em geral consideram vários procedimentos simultaneamente enquanto escrevem seu código. Os compiladores compilam cada procedimento isoladamente e precisam seguir convenções estritas governando o uso dos registradores nos limites de procedimento. Retendo valores comumente usados nos registradores, até mesmo entre os limites dos procedimentos, os programadores podem fazer um programa ser executado com mais rapidez. Outra vantagem importante do assembly está na capacidade de explorar instruções especializadas, por exemplo, instruções de cópia de string ou combinação de padrões. Os compiladores, na maior parte dos casos, não podem determinar se um loop de programa pode ser substituído por uma única
instrução. Contudo, o programador que escreveu o loop pode substituí-lo facilmente por uma única instrução. Hoje, a vantagem de um programador sobre um compilador tornou-se difícil de manter, pois as técnicas de compilação melhoram e os pipelines das máquinas aumentam de complexidade (Capítulo 4). O último motivo para usar assembly é que não existe uma linguagem de alto nível disponível em um computador específico. Computadores muito antigos ou especializados não possuem um compilador, de modo que a única alternativa de um programador é o assembly.
Desvantagens da linguagem assembly O assembly possui muitas desvantagens, que argumentam fortemente contra seu uso generalizado. Talvez sua principal desvantagem seja que os programas escritos em assembly são inerentemente específicos à máquina e precisam ser reescritos para serem executados em outra arquitetura de computador. A rápida evolução dos computadores, discutida no Capítulo 1, significa que as arquiteturas se tornam obsoletas. Um programa em assembly permanece firmemente ligado à sua arquitetura original, mesmo depois que o computador for substituído por máquinas mais novas, mais rápidas e mais econômicas. Outra desvantagem é que os programas em assembly são maiores do que os programas equivalentes escritos em uma linguagem de alto nível. Por exemplo, o programa em C da Figura A.1.5 possui 11 linhas de extensão, enquanto o programa em assembly da Figura A.1.4 possui 31 linhas. Em programas mais complexos, a razão entre o assembly e a linguagem de alto nível (seu fator de expansão) pode ser muito maior do que o fator de três nesse exemplo. Infelizmente, estudos empíricos mostraram que os programadores escrevem quase o mesmo número de linhas de código por dia em assembly e em linguagens de alto nível. Isso significa que os programadores são aproximadamente x vezes mais produtivos em uma linguagem de alto nível, onde x é o fator de expansão do assembly. Para aumentar o problema, programas maiores são mais difíceis de ler e entender e contêm mais bugs. O assembly realça esse problema, devido à sua completa falta de estrutura. Os idiomas de programação comuns, como instruções if-then e loops, precisam ser criados a partir de desvios e jumps. Os programas resultantes são difíceis de ler, pois o leitor precisa recriar cada construção de nível mais alto a partir de suas partes, e cada ocorrência de uma
instrução pode ser ligeiramente diferente. Por exemplo, veja a Figura A.1.4 e responda a estas perguntas: que tipo de loop é utilizado? Quais são seus limites inferior e superior?
Detalhamento Os compiladores podem produzir linguagem de máquina diretamente, em vez de contar com um assembler. Esses compiladores executam muito mais rapidamente do que aqueles que invocam um assembler como parte da compilação. Todavia, um compilador que gera linguagem de máquina precisa realizar muitas das tarefas que um assembler normalmente trata, como resolver endereços e codificar instruções como números binários. A escolha é entre velocidade de compilação e simplicidade do compilador.
Detalhamento Apesar dessas considerações, algumas aplicações embutidas são escritas em uma linguagem de alto nível. Muitas dessas aplicações são programas grandes e complexos, que precisam ser muito confiáveis. Os programas em assembly são maiores e mais difíceis de escrever e ler do que os programas em linguagem de alto nível. Isso aumenta bastante o custo da escrita de um programa em assembly e torna muito difícil verificar a exatidão desse tipo de programa. Na verdade, essas considerações levaram o Departamento de Defesa dos EUA, que paga por muitos sistemas embutidos complexos, a desenvolver a Ada, uma nova linguagem de alto nível para a escrita de sistemas embutidos.
A.2. Assemblers Um assembler traduz um arquivo de instruções em assembly para um arquivo de instruções de máquinas binárias e dados binários. O processo de tradução possui duas etapas principais. A primeira etapa é encontrar locais de memória com rótulos, de modo que o relacionamento entre os nomes simbólicos e endereços seja conhecido quando as instruções forem traduzidas. A segunda etapa é traduzir cada instrução assembly combinando os equivalentes numéricos dos opcodes (códigos de operação), especificadores de registradores e rótulos em uma instrução válida. Como vemos na Figura A.1.1, o assembler produz um arquivo de saída, chamado de arquivo-objeto, que contém as instruções de máquina, dados e informações de manutenção. Um arquivo-objeto normalmente não pode ser executado porque referencia procedimentos ou dados em outros arquivos. Um rótulo é externo (também chamado global) se o objeto rotulado puder ser referenciado a partir de arquivos diferentes de onde está definido. Um rótulo é local se o objeto só puder ser usado dentro do arquivo em que está definido. Na maior parte dos assemblers, os rótulos são locais por padrão e precisam ser declarados como globais explicitamente. As sub-rotinas e variáveis globais exigem rótulos externos, pois são referenciados a partir de muitos arquivos em um programa. Rótulos locais ocultam nomes que não devem ser visíveis a outros módulos — por exemplo, funções estáticas em C, que só podem ser chamadas por outras funções no mesmo arquivo. Além disso, nomes gerados pelo compilador — por exemplo, um nome para a instrução no início de um loop — são locais, de modo que o compilador não precisa produzir nomes exclusivos em cada arquivo.
rótulo externo Também chamado rótulo global. Um rótulo que se refere a um objeto que pode ser referenciado a partir de arquivos diferentes daquele em que está definido.
rótulo local Um rótulo que se refere a um objeto que só pode ser usado dentro do arquivo em que está definido.
Rótulos locais e globais Exemplo Considere o programa na Figura A.1.4. A sub-rotina possui um rótulo externo (global) main. Ela também contém dois rótulos locais — loop e str — visíveis apenas dentro do seu arquivo em assembly. Finalmente, a rotina também contém uma referência não resolvida a um rótulo externo printf, que é a rotina da biblioteca que exibe valores. Quais rótulos na Figura A.1.4 poderiam ser referenciados a partir de outro arquivo?
Resposta Somente os rótulos globais são visíveis fora de um arquivo, de modo que o único rótulo que poderia ser referenciado por outro arquivo é main. Como o assembler processa cada arquivo em um programa individual e isoladamente, ele só sabe os endereços dos rótulos locais. O assembler depende de outra ferramenta, o link-editor, para combinar uma coleção de arquivosobjeto e bibliotecas em um arquivo executável, resolvendo os rótulos externos. O assembler auxilia o link-editor, oferecendo listas de rótulos e referências não resolvidas. No entanto, até mesmo os rótulos locais apresentam um desafio interessante a um assembler. Ao contrário dos nomes na maioria das linguagens de alto nível, os rótulos em assembly podem ser usados antes de serem definidos. No exemplo, na Figura A.1.4, o rótulo str é usado pela instrução la antes de ser definido. A possibilidade de uma referência à frente, como essa, força o assembler a traduzir um programa em duas etapas: primeiro encontre todos os rótulos e depois produza as instruções. No exemplo, quando o assembler vê a instrução la, ele não sabe onde a palavra rotulada com str está localizada ou mesmo se str rotula uma instrução ou um dado.
referência à frente Um rótulo usado antes de ser definido. A primeira passada de um assembler lê cada linha de um arquivo em assembly e a divide em seus componentes. Essas partes, chamadas lexemas, são palavras,
números e caracteres de pontuação individuais. Por exemplo, a linha
contém seis lexemas: o opcode ble, o especificador de registrador $t0, uma vírgula, o número 100, uma vírgula e o símbolo loop. Se uma linha começa com um rótulo, o assembler registra em sua tabela de símbolos o nome do rótulo e o endereço da palavra de memória que a instrução ocupa. O assembler, então, calcula quantas palavras de memória ocupará a instrução na linha atual. Acompanhando os tamanhos das instruções, o assembler pode determinar onde a próxima instrução entrará. Para calcular o tamanho de uma instrução de tamanho variável, como aquelas no VAX, um assembler precisa examiná-la em detalhes. Por outro lado, instruções de tamanho fixo, como aquelas no MIPS, exigem apenas um exame superficial. O assembler realiza um cálculo semelhante para estabelecer o espaço exigido para instruções de dados. Quando o assembler atinge o final de um arquivo assembly, a tabela de símbolos registra o local de cada rótulo definido no arquivo.
tabela de símbolos Uma tabela que faz a correspondência entre os nomes dos rótulos e os endereços das palavras de memória que as instruções ocupam. O assembler utiliza as informações na tabela de símbolos durante uma segunda passada pelo arquivo, que, na realidade, produz o código de máquina. O assembler novamente examina cada linha no arquivo. Se a linha contém uma instrução, o assembler combina as representações binárias de seu opcode e operandos (especificadores de registradores ou endereço de memória) em uma instrução válida. O processo é semelhante ao usado na Seção 2.5 do Capítulo 2. As instruções e as palavras de dados que referenciam um símbolo externo definido em outro arquivo não podem ser completamente montadas (elas não estão resolvidas) porque o endereço do símbolo não está na tabela de símbolos. Um assembler não reclama sobre referências não resolvidas, porque o rótulo correspondente provavelmente estará definido em outro arquivo.
Colocando em perspectiva Assembly é uma linguagem de programação. Sua principal diferença das linguagens de alto nível, como BASIC, Java e C, é que o assembly oferece apenas alguns tipos simples de dados e fluxo de controle. Os programas em assembly não especificam o tipo do valor mantido em uma variável. Em vez disso, um programador precisa aplicar as operações apropriadas (por exemplo, adição de inteiro ou ponto flutuante) a um valor. Além disso, em assembly, os programas precisam implementar todo o fluxo de controle com instruções do tipo “go to”. Os dois fatores tornam a programação em assembly para qualquer máquina — MIPS ou x86 — mais difícil e passível de erro do que a escrita em uma linguagem de alto nível.
Detalhamento Se a velocidade de um assembler for importante, esse processo em duas etapas pode ser feito em uma passada pelo arquivo assembly com uma técnica conhecida como backpatching. Em sua passada pelo arquivo, o assembler monta uma representação binária (possivelmente incompleta) de cada instrução. Se a instrução referencia um rótulo ainda não definido, o assembler consulta essa tabela para encontrar todas as instruções que contêm uma referência à frente ao rótulo. O assembler volta e corrige sua representação binária para incorporar o endereço do rótulo. O backpatching agiliza o assembly porque o assembler só lê sua entrada uma vez. Contudo, isso exige que um assembler mantenha uma representação binária inteira de um programa na memória, de modo que as instruções possam sofrer backpatching. Esse requisito pode limitar o tamanho dos programas que podem ser montados. O processo é complicado por máquinas com diversos tipos de desvios que se espalham por diferentes intervalos de instruções. Quando o assembler vê inicialmente um rótulo não resolvido em uma instrução de desvio, ele precisa usar o maior desvio possível ou arriscar ter de voltar e reajustar muitas instruções para criar espaço em um desvio maior.
backpatching Um método para traduzir do assembly para instruções de máquina, em que o assembler tem uma representação binária (possivelmente incompleta) de cada instrução em uma passada por um programa e depois retorna para preencher
rótulos previamente indefinidos.
Formato do arquivo-objeto Os assemblers produzem arquivos-objeto. Um arquivo-objeto no UNIX contém seis seções distintas (veja Figura A.2.1): ▪ O cabeçalho do arquivo-objeto descreve o tamanho e a posição das outras partes do arquivo. ▪ O segmento de texto contém o código em linguagem de máquina para rotinas no arquivo-fonte. Essas rotinas podem ser não executáveis devido a referências não resolvidas.
FIGURA A.2.1 Arquivo-objeto. Um assembler do UNIX produz um arquivo-objeto com seis seções distintas.
segmento de texto O segmento de um arquivo-objeto do UNIX que contém o código em linguagem de máquina para as rotinas no arquivo-fonte. ▪ O segmento de dados contém uma representação binária dos dados no arquivo-fonte. Os dados também podem estar incompletos devido a referências não resolvidas a rótulos em outros arquivos.
segmento de dados O segmento de um objeto ou arquivo executável do UNIX que contém uma representação binária dos dados inicializados, usados pelo programa ▪ As informações de relocação identificam instruções e palavras de dados que dependem de endereços absolutos. Essas referências precisam mudar se partes do programa forem movidas na memória.
informações de relocação O segmento de um arquivo-objeto do UNIX que identifica instruções e palavras de dados que dependem de endereços absolutos.
endereço absoluto O endereço real na memória de uma variável ou rotina. ▪ A tabela de símbolos associa endereços a rótulos externos no arquivo-fonte e lista referências não resolvidas. ▪ As informações de depuração contêm uma descrição concisa da maneira como o programa foi compilado, de modo que um depurador possa descobrir quais endereços de instrução correspondem às linhas em um arquivo-fonte e exibir as estruturas de dados em formato legível. O assembler produz um arquivo-objeto que contém uma representação binária do programa e dos dados, além de informações adicionais para ajudar a ligar as partes de um programa. Essas informações de relocação são necessárias porque o assembler não sabe quais locais da memória um procedimento ou parte de dados ocupará depois de ser ligado ao restante do programa. Os procedimentos e dados de um arquivo são armazenados em uma parte contígua da memória, mas o assembler não sabe onde essa memória estará localizada. O assembler também passa algumas entradas da tabela de símbolos para o link-editor. Em particular, o assembler precisa registrar quais símbolos externos são definidos em um arquivo e quais referências não resolvidas ocorrem em um arquivo.
Detalhamento Por conveniência, os assemblers consideram que cada arquivo começa no mesmo endereço (por exemplo, posição 0) com a expectativa de que o linkeditor reposicione o código e os dados quando forem designados seus locais na memória. O assembler produz informações de relocação, que contêm uma entrada descrevendo cada instrução ou palavra de dados no arquivo que referencia um endereço absoluto. No MIPS, somente as instruções call, load e store da sub-rotina referenciam endereços absolutos. As instruções que usam endereçamento relativo ao PC, como desvios, não precisam ser relocadas.
Facilidades adicionais
Facilidades adicionais Os assemblers oferecem diversos recursos convenientes que ajudam a tornar os programas em assembly mais curtos e mais fáceis de escrever, mas não mudam fundamentalmente o assembly. Por exemplo, as diretivas de layout de dados permitem que um programador descreva os dados de uma maneira mais concisa e natural do que sua representação binária. Na Figura A.1.4, a diretiva
armazena caracteres da string na memória. Compare essa linha com a alternativa de escrever cada caractere como seu valor ASCII (a Figura 2.15, no Capítulo 2, descreve a codificação ASCII para os caracteres):
A diretiva .asciiz é mais fácil de ler porque representa caracteres como letras, e não como números binários. Um assembler pode traduzir caracteres para sua representação binária muito mais rapidamente e com mais precisão do que um ser humano. As diretivas de layout de dados especificam os dados em um formato legível aos seres humanos, que um assembler traduz para binário. Outras diretivas de layout são descritas na Seção A.10.
Diretiva de string Exemplo Defina a sequência de bytes produzida por esta diretiva:
Resposta
As macros são uma facilidade de combinação e troca de padrão, que oferece um mecanismo simples para nomear uma sequência de instruções usada com frequência. Em vez de digitar repetidamente as mesmas instruções toda vez que forem usadas, um programador chama a macro e o assembler substitui a chamada da macro pela sequência de instruções correspondente. As macros, como as sub-rotinas, permitem que um programador crie e nomeie uma nova abstração para uma operação comum. No entanto, diferente das sub- rotinas, elas não causam uma chamada e um retorno de sub-rotina quando o programa é executado, pois uma chamada de macro é substituída pelo corpo da macro quando o programa é montado. Depois dessa troca, a montagem resultante é indistinguível do programa equivalente, escrito sem macros.
Macros Exemplo Como um exemplo, suponha que um programador precise exibir muitos números. A rotina de biblioteca printf aceita uma string de formato e um ou mais valores para exibir como seus argumentos. Um programador poderia exibir o inteiro no registrador $7 com as seguintes instruções:
A diretiva .data diz ao assembler para armazenar a string no segmento de dados do programa, e a diretiva .text diz ao assembler para armazenar as instruções em seu segmento de texto. Entretanto, a exibição de muitos números dessa maneira é tediosa e produz um programa extenso, difícil de ser entendido. Uma alternativa é introduzir uma macro, print_int, para exibir um inteiro:
A macro possui um parâmetro formal, $arg, que nomeia o argumento da macro. Quando a macro é expandida, o argumento de uma chamada é substituído pelo parâmetro formal em todo o corpo da macro. Depois, o assembler substitui a chamada pelo corpo recém-expandido da macro. Na
primeira chamada em print_int, o argumento é $7, de modo que a macro se expande para o código
parâmetro formal Uma variável que é o argumento de um procedimento ou macro; ela é substituída por esse argumento quando a macro é expandida.
Em uma segunda chamada a print_int, digamos, print_int($t0), o argumento é $t0, de modo que a macro expande para:
Para o que a chamada print_int($a0) se expande?
Resposta
Esse exemplo ilustra uma desvantagem das macros. Um programador que utiliza essa macro precisa estar ciente de que print_int utiliza o registrador $a0 e por isso não pode exibir corretamente o valor nesse registrador.
Interface hardware/software Alguns assemblers também implementam pseudoinstruções, que são instruções fornecidas por um assembler mas não implementadas no hardware. O Capítulo 2 contém muitos exemplos de como o assembler MIPS sintetiza pseudoinstruções e modos de endereçamento do conjunto de instruções de hardware do MIPS. Por exemplo, a Seção 2.7, no Capítulo 2, descreve como o assembler sintetiza a instrução blt, a partir de duas outras instruções: slt e bne. Estendendo o conjunto de instruções, o assembler MIPS torna a programação em assembly mais fácil sem complicar o hardware. Muitas pseudoinstruções também poderiam ser simuladas com macros, mas o assembler MIPS pode gerar um código melhor para essas instruções, pois pode usar um registrador dedicado ($at) e é capaz de otimizar o código gerado.
Detalhamento Os assemblers montam condicionalmente partes de código, o que permite que um programador inclua ou exclua grupos de instruções quando um programa é montado. Esse recurso é particularmente útil quando várias versões de um programa diferem por um pequeno valor. Em vez de manter esses programas em arquivos separados — o que complica bastante o reparo de bugs no código comum —, os programadores normalmente mesclam as versões em um único arquivo. O código particular a uma versão é montado condicionalmente, de modo que possa ser excluído quando outras versões do programa forem montadas.
Se as macros e a montagem condicional são tão úteis, por que os assemblers para sistemas UNIX nunca ou quase nunca as oferecem? Um motivo é que a maioria dos programadores nesses sistemas escreve programas em linguagens de mais alto nível, como C. A maior parte do código assembly é produzida por compiladores, que acham mais conveniente repetir o código do que definir macros. Outro motivo é que outras ferramentas no UNIX — como cpp, o pré-processador C, ou m4, um processador de macro de uso geral — podem oferecer macros e montagem condicional para programas em assembly.
A.3. Link-editores A compilação separada permite que um programa seja dividido em partes que são armazenadas em arquivos diferentes. Cada arquivo contém uma coleção logicamente relacionada de sub-rotinas e estruturas de dados que formam um módulo de um programa maior. Um arquivo pode ser compilado e montado independente de outros arquivos, de modo que as mudanças em um módulo não exigem a recompilação do programa inteiro. Conforme já discutimos, a compilação separada necessita da etapa adicional de link-edição para combinar os arquivos-objeto de módulos separados e consertar suas referências não resolvidas.
compilação separada Dividir um programa em muitos arquivos, cada qual podendo ser compilado sem conhecimento do que está nos outros arquivos. A ferramenta que mescla esses arquivos é o link-editor (veja Figura A.3.1). Ele realiza três tarefas: ▪ Pesquisa as bibliotecas de programa para encontrar rotinas de biblioteca usadas pelo programa. ▪ Determina os locais da memória que o código de cada módulo ocupará e realoca suas instruções ajustando referências absolutas. ▪ Resolve referências entre os arquivos.
FIGURA A.3.1 O link-editor pesquisa uma coleção de arquivos-objeto e bibliotecas de programa para encontrar rotinas não locais usadas em um programa, combina-as em um único arquivo executável e resolve as referências entre as rotinas em arquivos diferentes.
A primeira tarefa de um link-editor é garantir que um programa não contenha rótulos indefinidos. O link-editor combina os símbolos externos e as referências não resolvidas, a partir dos arquivos de um programa. Um símbolo externo em um arquivo resolve uma referência de outro arquivo se ambos se referirem a um rótulo com o mesmo nome. As referências não combinadas significam que um símbolo foi usado, mas não definido em qualquer lugar do programa. Referências não resolvidas nesse estágio do processo de link-edição não necessariamente significam que um programador cometeu um erro. O programa poderia ter referenciado uma rotina de biblioteca cujo código não estava nos arquivos-objeto passados ao link-editor. Depois de combinar os símbolos no programa, o link-editor pesquisa as bibliotecas de programa do sistema para encontrar sub-rotinas e estruturas de dados predefinidas que o programa referência. As bibliotecas básicas contêm rotinas que leem e escrevem dados, alocam e liberam memória e realizam operações numéricas. Outras bibliotecas contêm rotinas para acessar bancos de dados ou manipular janelas de terminal. Um programa que referencia um símbolo não resolvido que não está em
qualquer biblioteca é errôneo e não pode ser link-editado. Quando o programa usa uma rotina de biblioteca, o link-editor extrai o código de rotina da biblioteca e o incorpora ao segmento de texto do programa. Essa nova rotina, por sua vez, pode depender de outras rotinas de biblioteca, de modo que o link-editor continua buscando outras rotinas de biblioteca até que nenhuma referência externa esteja não resolvida ou até que uma rotina não possa ser encontrada. Se todas as referências externas forem resolvidas, o link-editor em seguida determina os locais da memória que cada módulo ocupará. Como os arquivos foram montados isoladamente, o assembler não poderia saber onde as instruções ou os dados de um módulo seriam colocados em relação a outros módulos. Quando o link-editor coloca um módulo na memória, todas as referências absolutas precisam ser relocadas para refletir seu verdadeiro local. Como o linkeditor possui informações de relocação que identificam todas as referências relocáveis, ele pode eficientemente localizar e remendar essas referências. O link-editor produz um arquivo executável que pode ser executado em um computador. Normalmente, esse arquivo tem o mesmo formato de um arquivoobjeto, exceto que não contém referências não resolvidas ou informações de relocação.
A.4. Carregando Um programa que link-edita sem um erro pode ser executado. Antes de ser executado, o programa reside em um arquivo no armazenamento secundário, como um disco. Em sistemas UNIX, o kernel do sistema operacional traz o programa para a memória e inicia sua execução. Para iniciar um programa, o sistema operacional realiza as seguintes etapas: 1. Lê o cabeçalho do arquivo executável para determinar o tamanho dos segmentos de texto e de dados. 2. Cria um novo espaço de endereçamento para o programa. Esse espaço de endereçamento é grande o suficiente para manter os segmentos de texto e de dados, junto com um segmento de pilha (veja a Seção A.5). 3. Copia instruções e dados do arquivo executável para o novo espaço de endereçamento. 4. Copia argumentos passados ao programa para a pilha. 5. Inicializa os registradores da máquina. Em geral, a maioria dos registradores é apagada, mas o stack pointer precisa receber o endereço do primeiro local da pilha livre (veja a Seção A.5). 6. Desvia para a rotina de partida, que copia os argumentos do programa da pilha para os registradores e chama a rotina main do programa. Se a rotina main retornar, a rotina de partida termina o programa com a chamada do sistema exit.
A.5. Uso da memória As próximas seções elaboram a descrição da arquitetura MIPS apresentada anteriormente no livro. Os capítulos anteriores focaram principalmente no hardware e seu relacionamento com o software de baixo nível. Essas seções tratavam principalmente de como os programadores assembly utilizam o hardware do MIPS. Essas seções descrevem um conjunto de convenções seguido em muitos sistemas MIPS. Em sua maior parte, o hardware não impõe essas convenções. Em vez disso, elas representam um acordo entre os programadores para seguirem o mesmo conjunto de regras, de modo que o software escrito por diferentes pessoas possa atuar junto e fazer uso eficaz do hardware MIPS. Os sistemas baseados em processadores MIPS, normalmente, dividem a memória em três partes (veja Figura A.5.1). A primeira parte, próxima do início do espaço de endereçamento (começando no endereço 400000hexa), é o segmento de texto, que mantém as instruções do programa.
FIGURA A.5.1 Layout da memória.
A segunda parte, acima do segmento de texto, é o segmento de dados, dividido em mais duas partes. Os dados estáticos (começando no endereço 10000000hexa) contêm objetos cujo tamanho é conhecido pelo compilador e cujo tempo de vida — o intervalo durante o qual um programa pode acessá-los — é a execução inteira do programa. Por exemplo, em C, as variáveis globais são alocadas estaticamente, pois podem ser referenciadas, a qualquer momento, durante a execução de um programa. O link- editor atribui objetos estáticos a locais no segmento de dados e resolve referências a esses objetos.
dados estáticos A parte da memória que contém dados cujo tamanho é conhecido pelo compilador e cujo tempo de vida é a execução inteira do programa.
Interface hardware/software Como o segmento de dados começa muito acima do programa, no endereço 10000000hexa, as instruções load e store não podem referenciar diretamente os objetos de dados com seus campos de offset de 16 bits (veja Seção 2.5, no Capítulo 2). Por exemplo, para carregar a palavra no segmento de dados no endereço 10010020hexa para o registrador $v0, são necessárias duas instruções:
(O 0x antes de um número significa que ele é um valor hexadecimal. Por exemplo, 0x800 é 8000hexa ou 32.768dec.) Para evitar repetir a instrução lui em cada load e store, os sistemas MIPS normalmente dedicam um registrador ($gp) como um ponteiro global para o segmento de dados estático. Esse registrador contém o endereço 10008000hexa, de modo que as instruções load e store podem usar seus campos de 16 bits com sinal para acessar os primeiros 64KB do segmento de dados estático. Com esse ponteiro global, podemos reescrever o exemplo como uma única instrução:
Naturalmente, um ponteiro global torna os locais de endereçamento entre 10000000hexa–10010000hexa mais rápidos do que outros locais do heap. O compilador MIPS normalmente armazena variáveis globais nessa área, pois essas variáveis possuem locais fixos e se ajustam melhor do que outros dados globais, como arrays. Imediatamente acima dos dados estáticos estão os dados dinâmicos. Esses dados, como seu nome sugere, são alocados pelo programa enquanto ele é executado. Nos programas C, a rotina de biblioteca malloc localiza e retorna um novo bloco de memória. Como um compilador não pode prever quanta memória um programa alocará, o sistema operacional expande a área de dados dinâmica para atender à demanda. Conforme indica a seta para cima na figura, malloc expande a área dinâmica com a chamada do sistema sbrk, que faz com que o sistema operacional acrescente mais páginas ao espaço de endereçamento virtual do programa (veja Seção 5.7, no Capítulo 5) imediatamente acima do segmento de dados dinâmico. A terceira parte, o segmento de pilha do programa, reside no topo do espaço de endereçamento virtual (começando no endereço 7fffffffhexa). Assim como os dados dinâmicos, o tamanho máximo da pilha de um programa não é conhecido antecipadamente. À medida que o programa coloca valores na pilha, o sistema operacional expande o segmento de pilha para baixo, em direção ao segmento de dados.
segmento de pilha A parte da memória usada por um programa para manter frames de chamada de procedimento. Essa divisão de três partes da memória não é a única possível. Contudo, ela possui duas características importantes: os dois segmentos dinamicamente expansíveis são bastante distantes um do outro e eles podem crescer para usar o espaço de endereços inteiro de um programa.
A.6. Convenção para chamadas de procedimento As convenções que controlam o uso dos registradores são necessárias quando os procedimentos em um programa são compilados separadamente. Para compilar um procedimento em particular, um compilador precisa saber quais registradores pode usar e quais são reservados para outros procedimentos. As regras para usar os registradores são chamadas de convenções para uso dos registradores ou convenções para chamadas de procedimento. Como o nome sugere, essas regras são, em sua maior parte, convenções seguidas pelo software, em vez de regras impostas pelo hardware. No entanto, a maioria dos compiladores e programadores tenta seguir essas convenções estritamente, pois sua violação causa bugs traiçoeiros.
convenção para uso dos registradores Também chamada convenção para chamadas de procedimento. Um protocolo de software que controla o uso dos registradores por procedimentos. A convenção para chamadas descrita nesta seção é aquela utilizada pelo compilador gcc. O compilador nativo do MIPS utiliza uma convenção mais complexa, que é ligeiramente mais rápida. A CPU do MIPS contém 32 registradores de uso geral, numerados de 0 a 31. O registrador $0 contém o valor fixo 0. ▪ Os registradores $at (1), $k0 (26) e $k1 (27) são reservados para o assembler e o sistema operacional e não devem ser usados por programas do usuário ou compiladores. ▪ Os registradores $a0—$a3 (4-7) são usados para passar os quatro primeiros argumentos às rotinas (os argumentos restantes são passados na pilha). Os registradores $v0 e $v1 (2, 3) são usados para retornar valores das funções. ▪ Os registradores $t0—$t9 (8-15, 24, 25) são registradores salvos pelo caller, usados para manter quantidades temporárias que não precisam ser preservadas entre as chamadas (veja Seção 2.8, no Capítulo 2). ▪ Os registradores $s0—$s7 (16-23) são registradores salvos pelo callee, que mantêm valores de longa duração os quais devem ser preservados entre as
chamadas. ▪ O registrador $gp (28) é um ponteiro global que aponta para o meio de um bloco de 64K de memória no segmento de dados estático. ▪ O registrador $sp (29) é o stack pointer, que aponta para o último local na pilha. O registrador $fp (30) é o frame pointer. A instrução jal escreve no registrador $ra (31) o endereço de retorno de uma chamada de procedimento. Esses dois registradores são explicados na próxima seção.
registrador salvo pelo caller Um registrador salvo pela rotina que faz uma chamada de procedimento.
registrador salvo pelo callee Um registrador salvo pela rotina sendo chamada. As abreviações e os nomes de duas letras para esses registradores — por exemplo, $sp para o stack pointer — refletem os usos intencionados na convenção de chamada de procedimento. Ao descrever essa convenção, usaremos os nomes em vez de números de registrador. A Figura A.6.1 lista os registradores e descreve seus usos intencionados.
FIGURA A.6.1 Registradores do MIPS e convenção de uso.
Chamadas de procedimento Esta seção descreve as etapas que ocorrem quando um procedimento (caller) invoca outro procedimento (callee). Os programadores que escrevem em uma linguagem de alto nível (como C ou Pascal) nunca veem os detalhes de como um procedimento chama outro, pois o compilador cuida dessa manutenção de baixo
nível. Contudo, os programadores assembly precisam implementar explicitamente cada chamada e retorno de procedimento. A maior parte da manutenção associada a uma chamada gira em torno de um bloco de memória chamado frame de chamada de procedimento. Essa memória é usada para diversas finalidades:
frame de chamada de procedimento Um bloco de memória usado para manter valores passados a um procedimento como argumentos, a fim de salvar registradores que um procedimento pode modificar mas que o caller não deseja que sejam alterados, e fornecer espaço para variáveis locais a um procedimento. ▪ Para manter valores passados a um procedimento como argumentos. ▪ Para salvar registradores que um procedimento pode modificar, mas que o caller não deseja que sejam alterados. ▪ Para oferecer espaço para variáveis locais a um procedimento. Na maioria das linguagens de programação, as chamadas e retornos de procedimento seguem uma ordem estrita do tipo último a entrar, primeiro a sair (LIFO — Last-In, First-Out), de modo que essa memória pode ser alocada e liberada como uma pilha, motivo pelo qual esses blocos de memória às vezes são chamados frames de pilha. A Figura A.6.2 mostra um frame de pilha típico. O frame consiste na memória entre o frame pointer ($fp), que aponta para a primeira palavra do frame, e o stack pointer ($sp), que aponta para a última palavra do frame. A pilha cresce para baixo a partir dos endereços de memória mais altos, de modo que o frame pointer aponta para cima do stack pointer. O procedimento que está executando utiliza o frame pointer para acessar rapidamente os valores em seu frame de pilha. Por exemplo, um argumento no frame de pilha pode ser lido para o registrador $v0 com a instrução
FIGURA A.6.2 Layout de um frame de pilha. O frame pointer ($fp) aponta para a primeira palavra do frame de pilha do procedimento em execução. O stack pointer ($sp) aponta para a última palavra do frame. Os quatro primeiros argumentos são passados em registradores, de modo que o quinto argumento é o primeiro armazenado na pilha.
Um frame de pilha pode ser construído de muitas maneiras diferentes; porém, o caller e o callee precisam combinar a sequência de etapas. As etapas a seguir descrevem a convenção de chamada utilizada na maioria das máquinas MIPS.
Essa convenção entra em ação em três pontos durante uma chamada de procedimento: imediatamente antes do caller invocar o callee, assim que o callee começa a executar e imediatamente antes do callee retornar ao caller. Na primeira parte, o caller coloca os argumentos da chamada de procedimento em locais padrões e invoca o callee para fazer o seguinte: 1. Passar argumentos. Por convenção, os quatro primeiros argumentos são passados nos registradores $a0–$a3. Quaisquer argumentos restantes são colocados na pilha e aparecem no início do frame de pilha do procedimento chamado. 2. Salvar registradores salvos pelo caller. O procedimento chamado pode usar esses registradores ($a0–$a3 e $t0–$t9) sem primeiro salvar seu valor. Se o caller espera utilizar um desses registradores após uma chamada, ele deverá salvar seu valor antes da chamada. 3. Executar uma instrução jal (veja Seção 2.8 do Capítulo 2), que desvia para a primeira instrução do callee e salva o endereço de retorno no registrador $ra. Antes que uma rotina chamada comece a executar, ela precisa realizar as seguintes etapas para configurar seu frame de pilha: 1. Alocar memória para o frame, subtraindo o tamanho do frame do stack pointer. 2. Salvar os registradores salvos pelo callee no frame. Um callee precisa salvar os valores desses registradores ($s0–$s7, $fp e $ra) antes de alterá-los, pois o caller espera encontrar esses registradores inalterados após a chamada. O registrador $fp é salvo para cada procedimento que aloca um novo frame de pilha. No entanto, o registrador $ra só precisa ser salvo se o callee fizer uma chamada. Os outros registradores salvos pelo callee, que são utilizados, também precisam ser salvos. 3. Estabelecer o frame pointer somando o tamanho do frame de pilha menos 4 a $sp e armazenando a soma no registrador $fp.
Interface hardware/software A convenção de uso dos registradores do MIPS oferece registradores salvos pelo caller e pelo callee, pois os dois tipos de registradores são vantajosos em circunstâncias diferentes. Os registradores salvos pelo caller são usados para manter valores de longa duração, como variáveis de um programa do usuário. Esses registradores só são salvos durante uma chamada de procedimento se o
caller espera utilizar o registrador. Por outro lado, os registradores salvos pelo callee são usados para manter quantidades de curta duração, que não persistem entre as chamadas, como valores imediatos em um cálculo de endereço. Durante uma chamada, o caller não pode usar esses registradores para valores temporários de curta duração. Finalmente, o callee retorna ao caller executando as seguintes etapas: 1. Se o callee for uma função que retorna um valor, coloque o valor retornado no registrador $v0. 2. Restaure todos os registradores salvos pelo callee que foram salvos na entrada do procedimento. 3. Remova o frame de pilha adicionando o tamanho do frame a $sp. 4. Retorne desviando para o endereço no registrador $ra.
Detalhamento Uma linguagem de programação que não permite procedimentos recursivos — procedimentos que chamam a si mesmos, direta ou indiretamente, por meio de uma cadeia de chamadas — não precisa alocar frames em uma pilha. Em uma linguagem não recursiva, o frame de cada procedimento pode ser alocado estaticamente, pois somente uma invocação de um procedimento pode estar ativa ao mesmo tempo. As versões mais antigas de Fortran proibiam a recursão porque frames alocados estaticamente produziam código mais rápido em algumas máquinas mais antigas. Todavia, em arquiteturas load-store, como MIPS, os frames de pilha podem ser tão rápidos quanto, porque o registrador frame pointer aponta diretamente para o frame de pilha ativo, o que permite que uma única instrução load ou store acesse valores no frame. Além disso, a recursão é uma técnica de programação valiosa.
procedimentos recursivos Procedimentos que chamam a si mesmos, direta ou indiretamente, por meio de uma cadeia de chamadas.
Exemplo de chamada de procedimento Como exemplo, considere a rotina em C
que calcula e exibe 10! (o fatorial de 10, 10! = 10 × 9 × ... × 1). fact é uma rotina recursiva que calcula n! multiplicando n vezes (n – 1)!. O código assembly para essa rotina ilustra como os programas manipulam frames de pilha. Na entrada, a rotina main cria seu frame de pilha e salva os dois registradores salvos pelo callee que serão modificados: $fp e $ra. O frame é maior do que o exigido para esses dois registradores, pois a convenção de chamada exige que o tamanho mínimo de um frame de pilha seja 24 bytes. Esse frame mínimo pode manter quatro registradores de argumento ($a0–$a3) e o endereço de retorno $ra, preenchidos até um limite de dupla palavra (24 bytes). Como main também precisa salvar o $fp, seu frame de pilha precisa ter duas palavras a mais (lembrese de que o stack pointer é mantido alinhado em um limite de dupla palavra).
A rotina main, então, chama a rotina de fatorial e lhe passa o único argumento 10. Depois que fact retorna, main chama a rotina de biblioteca printf e lhe passa uma string de formato e o resultado retornado de fact:
Finalmente, depois de exibir o fatorial, main retorna. Entretanto, primeiro, ela precisa restaurar os registradores que salvou e remover seu frame de pilha:
A rotina de fatorial é semelhante em estrutura a main. Primeiro, ela cria um frame de pilha e salva os registradores salvos pelo callee que serão usados por ela. Além de salvar $ra e $fp, fact também salva seu argumento ($a0), que ela usará para a chamada recursiva:
O núcleo da rotina fact realiza o cálculo do programa em C. Ele testa se o argumento é maior do que 0. Se não for, a rotina retorna o valor 1. Se o argumento for maior do que 0, a rotina é chamada recursivamente para calcular
fact(n-1) e multiplica esse valor por n:
Finalmente, a rotina de fatorial restaura os registradores salvos pelo callee e retorna o valor no registrador $v0:
Pilha em procedimentos recursivos Exemplo A Figura A.6.3 mostra a pilha na chamada fact(7). main executa primeiro, de modo que seu frame está mais abaixo na pilha. main chama fact(10), cujo frame de pilha vem em seguida na pilha. Cada invocação chama fact recursivamente para calcular o próximo fatorial mais inferior. Os frames de pilha fazem um paralelo com a ordem LIFO dessas chamadas. Qual é a aparência da pilha quando a chamada para o fact(10) retorna?
FIGURA A.6.3 Frames da pilha durante a chamada de fact(7).
Resposta
Resposta
Detalhamento A diferença entre o compilador MIPS e o compilador gcc é que o compilador MIPS normalmente não usa um frame pointer, de modo que esse registrador está disponível como outro registrador salvo pelo callee, $s8. Essa mudança salva algumas das instruções na chamada de procedimento e sequência de retorno. Contudo, isso complica a geração do código, porque um procedimento precisa acessar seu frame de pilha com $sp, cujo valor pode mudar durante a execução de um procedimento se os valores forem colocados na pilha.
Outro exemplo de chamada de procedimento Como outro exemplo, considere a seguinte rotina que calcula a função tak, que é um benchmark bastante utilizado, criado por Ikuo Takeuchi. Essa função não calcula nada de útil, mas é um programa altamente recursivo que ilustra a convenção de chamada do MIPS.
O código assembly para esse programa está logo em seguida. A função tak primeiro salva seu endereço de retorno no frame de pilha e seus argumentos nos registradores salvos pelo callee, pois a rotina pode fazer chamadas que precisam usar os registradores $a0–$a2 e $ra. A função utiliza registradores salvos pelo callee, pois eles mantêm valores que persistem por toda a vida da função, o que inclui várias chamadas que potencialmente poderiam modificar registradores.
A rotina, então, inicia a execução testando se y < x. Se não for, ela desvia para o rótulo L1, que aparece a seguir.
Se y < x, então ela executa o corpo da rotina, que contém quatro chamadas recursivas. A primeira chamada usa quase os mesmos argumentos do seu pai:
Observe que o resultado da primeira chamada recursiva é salvo no registrador
$s3, de modo que possa ser usado mais tarde.
A função agora prepara argumentos para a segunda chamada recursiva.
Nas instruções a seguir, o resultado dessa chamada recursiva é salvo no registrador $s0. No entanto, primeiro, precisamos ler, pela última vez, o valor salvo do primeiro argumento a partir desse registrador.
Depois de três chamadas recursivas mais internas, estamos prontos para a chamada recursiva final. Depois da chamada, o resultado da função está em $v0, e o controle desvia para o epílogo da função.
Esse código no rótulo L1 é a consequência da instrução if-then-else. Ele apenas move o valor do argumento z para o registrador de retorno e cai no epílogo da função.
O código a seguir é o epílogo da função, que restaura os registradores salvos e retorna o resultado da função a quem chamou.
A rotina main chama a função tak com seus argumentos iniciais, depois pega o resultado calculado (7) e o apresenta usando a chamada ao sistema do SPIM para exibir inteiros:
A.7. Exceções e interrupções A Seção 4.9 do Capítulo 4 descreve o mecanismo de exceção do MIPS, que responde a exceções causadas por erros durante a execução de uma instrução e a interrupções externas causadas por dispositivos de E/S. Esta seção descreve o tratamento de interrupção e exceção com mais detalhes.1 Nos processadores MIPS, uma parte da CPU, chamada coprocessador 0, registra as informações de que o software precisa para lidar com exceções e interrupções. O simulador SPIM do MIPS não implementa todos os registradores do coprocessador 0, pois muitos não são úteis em um simulador ou fazem parte do sistema de memória, que o SPIM não implementa. Contudo, o SPIM oferece os seguintes registradores do coprocessador 0:
tratamento de interrupção Um trecho de código executado como resultado de uma exceção ou interrupção. Nome do registrador
Número do registrador
Uso
BadVAddr
8
endereço de memória em que ocorreu uma referência de memória problemática
Count
9
temporizador
Compare
11
valor comparado com o temporizador que causa interrupção quando combinam
Status
12
máscara de interrupções e bits de habilitação
Cause
13
tipo de exceção e bits de interrupções pendentes
EPC
14
endereço da instrução que causou a exceção
Config
16
configuração da máquina
Esses sete registradores fazem parte do conjunto de registradores do coprocessador 0. Eles são acessados pelas instruções mfc0 e mtc0. Após uma exceção, o registrador EPC contém o endereço da instrução executada quando a exceção ocorreu. Se a exceção foi causada por uma interrupção externa, então a instrução não terá iniciado a execução. Todas as outras exceções são causadas pela execução da instrução no EPC, exceto quando a instrução problemática está no delay slot de um desvio ou salto. Nesse caso, o EPC aponta para a instrução
de desvio ou salto e o bit BD é habilitado no registrador Cause. Quando esse bit está habilitado, o handler de exceção precisa procurar a instrução problemática em EPC + 4. No entanto, de qualquer forma, um handler de exceção retoma o programa corretamente, retornando à instrução no EPC. Se a instrução que causou a exceção fez um acesso à memória, o registrador BadVAddr contém o endereço do local de memória referenciado. O registrador Count é um timer que incrementa em uma taxa fixa (como padrão, a cada 10 milissegundos) enquanto o SPIM está executando. Quando o valor no registrador Count for igual ao valor no registrador Compare, ocorre uma interrupção de hardware com nível de prioridade 5. A Figura A.7.1 mostra o subconjunto dos campos do registrador Status implementados pelo simulador SPIM do MIPS. O campo interrupt mask contém um bit para cada um dos seis níveis de interrupção de hardware e dois de software. Um bit de máscara 1 permite que as interrupções nesse nível parem o processador. Um bit de máscara 0 desativa as interrupções nesse nível. Quando uma interrupção chega, ela habilita seu bit de interrupção pendente no registrador Cause, mesmo que o bit de máscara esteja desabilitado. Quando uma interrupção está pendente, ela interromperá o processador quando seu bit de máscara for habilitado mais tarde.
FIGURA A.7.1 O registrador Status.
O bit de modo usuário é 0 se o processador estiver funcionando no modo kernel e 1 se estiver funcionando no modo usuário. No SPIM, esse bit é fixado em 1, pois o processador SPIM não implementa o modo kernel. O bit de nível de exceção normalmente é 0, mas é colocado em 1 depois que ocorre uma exceção. Quando esse bit é 1, as interrupções são desativadas e o EPC não é atualizado se outra exceção ocorrer. Esse bit impede que um handler de exceção seja
incomodado por uma interrupção ou exceção, mas deve ser reiniciado quando o handler termina. Se o bit interrupt enable for 1, as interrupções são permitidas. Se for 0, elas serão inibidas. A Figura A.7.2 mostra o subconjunto dos campos do registrador Cause que o SPIM implementa. O bit de branch delay é 1 se a última exceção ocorreu em uma instrução executada no slot de retardo de um desvio. Os bits de interrupções pendentes tornam-se 1 quando uma interrupção é gerada em determinado nível de hardware ou software. O registrador de código de exceção descreve a causa de uma exceção por meio dos seguintes códigos:
FIGURA A.7.2 O registrador Cause.
Número Nome 0
Int
Causa da exceção interrupção (hardware)
4
AdEL exceção de erro de endereço (load ou busca de instrução)
5
AdES exceção de erro de endereço (store)
6
IBE
erro de barramento na busca da instrução
7
DBE
erro de barramento no load ou store de dados
8
Sys
exceção de chamada do sistema (syscall)
9
Bp
exceção de ponto de interrupção (breakpoint)
10
RI
exceção de instrução reservada
11
CpU
12
Ov
exceção de overflow aritmético
13
Tr
interceptação (trap)
15
FPE
coprocessador não implementado
ponto flutuante
Exceções e interrupções fazem com que um processador MIPS desvie para uma parte do código, no endereço 80000180hexa (no espaço de endereçamento do kernel, não do usuário), chamada handler de exceção. Esse código examina a causa da exceção e desvia para um ponto apropriado no sistema operacional. O sistema operacional responde a uma exceção terminando o processo que causou
a exceção ou realizando alguma ação. Um processo que causa um erro, como a execução de uma instrução não implementada, é terminado pelo sistema operacional. Por outro lado, outras exceções, como faltas de página, são solicitações de um processo para o sistema operacional realizar um serviço, como trazer uma página do disco. O sistema operacional processa essas solicitações e retoma o processo. O último tipo de exceções são interrupções de dispositivos externos. Elas em geral fazem com que o sistema operacional mova dados de/para um dispositivo de E/S e retome o processo interrompido. O código no exemplo a seguir é um handler de exceção simples, que invoca uma rotina para exibir uma mensagem a cada exceção (mas não interrupções). Esse código é semelhante ao handler de exceção (exceptions.s) usado pelo simulador SPIM.
Handler de exceções Exemplo O handler de exceção primeiro salva o registrador $at, que é usado em pseudoinstruções no código do handler, depois salva $a0 e $a1, que mais tarde utiliza para passar argumentos. O handler de exceção não pode armazenar os valores antigos a partir desses registradores na pilha, como faria uma rotina comum, pois a causa da exceção poderia ter sido uma referência de memória que usou um valor incorreto (como 0) no stack pointer. Em vez disso, o handler de exceção armazena esses registradores em um registrador de handler de exceção ($k1, pois não pode acessar a memória sem usar $at) e dois locais da memória (save0 e save1). Se a própria rotina de exceção pudesse ser interrompida, dois locais não seriam suficientes, pois a segunda exceção gravaria sobre valores salvos durante a primeira exceção. Entretanto, esse handler de exceção simples termina a execução antes de permitir interrupções, de modo que o problema não surge.
O handler de exceção, então, move os registradores Cause e EPC para os registradores da CPU. Os registradores Cause e EPC não fazem parte do conjunto de registradores da CPU. Em vez disso, eles são registradores no coprocessador 0, que é a parte da CPU que trata das exceções. A instrução mfc0 $k0, $13 move o registrador 13 do coprocessador 0 (o registrador Cause) para o registrador da CPU $k0. Observe que o handler de exceção não precisa salvar os registradores $k0 e $k1, pois os programas do usuário não deveriam usar esses registradores. O handler de exceção usa o valor do registrador Cause para testar se a exceção foi causada por uma interrupção (ver a tabela anterior). Se tiver sido, a exceção é ignorada. Se a exceção não foi uma interrupção, o handler chama print_excp para apresentar uma mensagem.
Antes de retornar, o handler de exceção apaga o registrador Cause, reinicia o registrador Status para ativar interrupções e limpar o bit EXL, de modo a permitir que exceções subsequentes mudem o registrador EPC, e restaura os registradores $a0, $a1 e $at. Depois, ele executa a instrução eret (retorno de exceção), que retorna à instrução apontada pelo EPC. Esse handler de exceção retorna à instrução após aquela que causou a exceção, a fim de não reexecutar a instrução que falhou e causar a mesma exceção novamente.
Detalhamento Em processadores MIPS reais, o retorno de um handler de exceção é mais complexo. O handler de exceção não pode sempre desviar para a instrução após o EPC. Por exemplo, se a instrução que causou a exceção estivesse em um delay slot de uma instrução de desvio (veja Capítulo 4), a próxima instrução a executar pode não ser a instrução seguinte na memória.
A.8. Entrada e saída O SPIM simula um dispositivo de E/S: um console mapeado em memória em que um programa pode ler e escrever caracteres. Quando um programa está executando, o SPIM conecta seu próprio terminal (ou uma janela de console separada na versão xspim do X-Windows ou na versão PCSpim do Windows) ao processador. Um programa MIPS executando no SPIM pode ler os caracteres que você digita. Além disso, se o programa MIPS escreve caracteres no terminal, eles aparecem no terminal do SPIM ou na janela de console. Uma exceção a essa regra é Control-C: esse caractere não passa pelo programa, mas, em vez disso, faz com que o SPIM pare e retorne ao modo de comando. Quando o programa para de executar (por exemplo, porque você pressionou Control-C ou porque o programa atingiu um ponto de interrupção), o terminal é novamente conectado ao SPIM para que você possa digitar comandos do SPIM. Para usar a E/S mapeada em memória (ver a seguir), o spim ou o xspim precisam ser iniciados com o flag -mapped_io. PCSpim pode ativar a E/S mapeada em memória por meio de um flag de linha de comando ou pela caixa de diálogo “Settings” (Configurações). O dispositivo de terminal consiste em duas unidades independentes: um receptor e um transmissor. O receptor lê caracteres digitados no teclado. O transmissor exibe caracteres no vídeo. As duas unidades são completamente independentes. Isso significa, por exemplo, que os caracteres digitados no teclado não são reproduzidos automaticamente no monitor. Em vez disso, um programa reproduz um caractere lendo-o do receptor e escrevendo-o no transmissor. Um programa controla o terminal com quatro registradores de dispositivo mapeados em memória, como mostra a Figura A.8.1. “Mapeado em memória” significa que cada registrador aparece como uma posição de memória especial. O registrador Receiver Control está na posição ffff0000hexa. Somente dois de seus bits são realmente usados. O bit 0 é chamado “pronto”: se for 1, isso significa que um caractere chegou do teclado, mas ainda não foi lido do registrador Receiver Data. O bit de pronto é apenas de leitura: tentativas de sobrescrevê-lo são ignoradas. O bit de pronto muda de 0 a 1 quando um caractere é digitado no teclado e ele muda de 1 para 0 quando o caractere é lido do registrador Receiver Data.
FIGURA A.8.1 O terminal é controlado por quatro registradores de dispositivo, cada um deles parecendo com uma posição de memória no endereço indicado. Somente alguns bits desses registradores são realmente utilizados. Os outros sempre são lidos como 0s e as escritas são ignoradas.
O bit 1 do registrador Receiver Control é o “interrupt enable” do teclado. Esse bit pode ser lido e escrito por um programa. O interrupt enable inicialmente é 0. Se ele for colocado em 1 por um programa, o terminal solicita uma interrupção no nível de hardware 1 sempre que um caractere é digitado e o bit de pronto se torna 1. Todavia, para que a interrupção afete o processador, as interrupções também precisam estar ativadas no registrador Status (Seção A.7). Todos os outros bits do registrador Receiver Control não são utilizados. O segundo registrador de dispositivo do terminal é o registrador Receiver Data (no endereço ffff0004hexa). Os oito bits menos significativos desse registrador contêm o último caractere digitado no teclado. Todos os outros bits
contêm 0s. Esse registrador é apenas de leitura e muda apenas quando um novo caractere é digitado no teclado. A leitura do registrador Receiver Data reinicia o bit de pronto no registrador Receiver Control para 0. O valor nesse registrador é indefinido se o registrador Receiver Control for 0. O terceiro registrador de dispositivo do terminal é o registrador Transmitter Control (no endereço ffff0008hexa). Somente os dois bits menos significativos desse registrador são usados. Eles se comportam de modo semelhante aos bits correspondentes no registrador Receiver Control. O bit 0 é chamado de “pronto” e é apenas de leitura. Se esse bit for 1, o transmissor estará pronto para aceitar um novo caractere para saída. Se for 0, o transmissor ainda está ocupado escrevendo o caractere anterior. O bit 1 é “interrupt enable” e pode ser lido e escrito. Se esse bit for definido em 1, então o terminal solicita uma interrupção no nível de hardware 0 sempre que o transmissor estiver pronto para um novo caractere e o bit de pronto se torna 1. O registrador de dispositivo final é o registrador Transmitter Data (no endereço ffff000chexa). Quando um valor é escrito nesse local, seus oito bits menos significativos (ou seja, um caractere ASCII como na Figura 2.15, no Capítulo 2) são enviados para o console. Quando o registrador Transmitter Data é escrito, o bit de pronto no registrador Transmitter Control é retornado para 0. Esse bit permanece sendo 0 até passar tempo suficiente para transmitir o caractere para o terminal; depois, o bit de pronto se torna 1 novamente. O registrador Transmitter Data só deverá ser escrito quando o bit de pronto do registrador Transmitter Control for 1. Se o transmissor não estiver pronto, as escritas no registrador Transmitter Data são ignoradas (as escritas parecem ter sucesso, mas o caractere não é enviado). Computadores reais exigem tempo para enviar caracteres a um console ou terminal. Esses atrasos de tempo são simulados pelo SPIM. Por exemplo, depois que o transmissor começa a escrever um caractere, o bit de pronto do transmissor torna-se 0 por um tempo. O SPIM mede o tempo em instruções executadas, e não em tempo de clock real. Isso significa que o transmissor não fica pronto novamente até que o processador execute um número fixo de instruções. Se você interromper a máquina e examinar o bit de pronto, ele não mudará. Contudo, se você deixar a máquina executar, o bit por fim mudará de volta para 1.
A.9. SPIM SPIM é um simulador de software que executa programas em assembly escritos para processadores que implementam a arquitetura MIPS-32, especificamente o Release 1 dessa arquitetura com um mapeamento de memória fixo, sem caches e apenas os coprocessadores 0 e 1.2 O nome do SPIM é simplesmente MIPS ao contrário. O SPIM pode ler e executar imediatamente os arquivos em assembly. O SPIM é um sistema autocontido para executar programas do MIPS. Ele contém um depurador e oferece alguns serviços de forma semelhante ao sistema operacional. SPIM é muito mais lento do que um computador real (100 ou mais vezes). Entretanto, seu baixo custo e grande disponibilidade não têm comparação com o hardware real! Uma pergunta óbvia é: por que usar um simulador quando a maioria das pessoas possui PCs que contêm processadores executando muito mais rápido do que o SPIM? Um motivo é que o processador nos PCs são 80x86s da Intel, cuja arquitetura é muito menos regular e muito mais complexa de entender e programar do que os processadores MIPS. A arquitetura do MIPS pode ser a síntese de uma máquina RISC simples e limpa. Além disso, os simuladores podem oferecer um ambiente melhor para a programação em assembly do que uma máquina real, pois podem detectar mais erros e oferecer uma interface melhor do que um computador real. Finalmente, os simuladores são uma ferramenta útil no estudo de computadores e dos programas executados. Como eles são implementados em software, não em silício, os simuladores podem ser examinados e facilmente modificados para acrescentar novas instruções, criar novos sistemas, como os multiprocessadores ou apenas coletar dados.
Simulação de uma máquina virtual Uma arquitetura MIPS básica é difícil de programar diretamente, por causa dos delayed branches, delayed loads e modos de endereçamento restritos. Essa dificuldade é tolerável, pois esses computadores foram projetados para serem programados em linguagens de alto nível e apresentam uma interface criada para compiladores, em vez de programadores assembly. Boa parte da complexidade da programação é resultante de instruções delayed. Um delayed branch exige dois ciclos para executar (veja as seções “Detalhamentos” nas páginas 250 e 282
do Capítulo 4). No segundo ciclo, a instrução imediatamente após o desvio é executada. Essa instrução pode realizar um trabalho útil que normalmente teria sido feito antes do desvio. Ela também pode ser um nop (nenhuma operação), que não faz nada. De modo semelhante, os delayed loads exigem dois ciclos para trazer um valor da memória, de modo que a instrução imediatamente após um load não pode usar o valor (veja Seção 4.2 do Capítulo 4). O MIPS sabiamente escolheu ocultar essa complexidade fazendo com que seu assembler implemente uma máquina virtual. Esse computador virtual parece ter branches e loads não delayed e um conjunto de instruções mais rico do que o hardware real. O assembler reorganiza instruções para preencher os delay slots. O computador virtual também oferece pseudoinstruções, que aparecem como instruções reais nos programas em assembly. O hardware, porém, não sabe nada a respeito de pseudoinstruções, de modo que o assembler as traduz para sequências equivalentes de instruções de máquina reais. Por exemplo, o hardware do MIPS só oferece instruções para desvio quando um registrador é igual ou diferente de 0. Outros desvios condicionais, como aquele que desvia quando um registrador é maior do que outro, são sintetizados comparando-se os dois registradores e desviando quando o resultado da comparação é verdadeiro (diferente de zero).
máquina virtual Um computador virtual que parece ter desvios e loads não delayed e um conjunto de instruções mais rico do que o hardware real. Como padrão, o SPIM simula a máquina virtual mais rica, pois essa é a máquina que a maioria dos programadores achará útil. Todavia, o SPIM também pode simular os desvios e loads delayed no hardware real. A seguir, descrevemos a máquina virtual e só mencionamos rapidamente os recursos que não pertencem ao hardware real. Ao fazer isso, seguimos a convenção dos programadores (e compiladores) assembly do MIPS, que normalmente utilizam a máquina estendida como se estivesse implementada em silício.
Introdução ao SPIM O restante deste apêndice é uma introdução ao SPIM e à linguagem assembly MIPS R2000. Muitos detalhes nunca irão preocupá-lo; porém, o grande volume de informações, às vezes, poderá obscurecer o fato de que o SPIM é um
programa simples e fácil de usar. Esta seção começa com um tutorial rápido sobre o uso do SPIM, que deverá permitir que você carregue, depure e execute programas MIPS simples. O SPIM vem em diferentes versões para diferentes tipos de sistemas. A única constante é a versão mais simples, chamada spim, que é um programa controlado por linha de comandos, executado em uma janela de console. Ele opera como a maioria dos programas desse tipo: você digita uma linha de texto, pressiona a tecla Enter (ou Return) e o spim executa seu comando. Apesar da falta de uma interface sofisticada, o spim pode fazer tudo que seus primos mais sofisticados fazem. Existem dois primos sofisticados do spim. A versão que roda no ambiente XWindows de um sistema UNIX ou Linux é chamada xspim. xspim é um programa mais fácil de aprender e usar do que o spim, pois seus comandos sempre são visíveis na tela e porque ele continuamente apresenta os registradores e a memória da máquina. A outra versão sofisticada se chama PCspim e roda no Microsoft Windows.
Recursos surpreendentes Embora o SPIM fielmente simule o computador MIPS, o SPIM é um simulador, e certas coisas não são idênticas a um computador real. As diferenças mais óbvias são que a temporização da instrução e o sistema de memória não são idênticos. O SPIM não simula caches ou a latência da memória, nem reflete com precisão os atrasos na operação de ponto flutuante ou nas instruções de multiplicação e divisão. Além disso, as instruções de ponto flutuante não detectam muitas condições de erro, o que deveria causar exceções em uma máquina real. Outra surpresa (que também ocorre na máquina real) é que uma pseudoinstrução se expande para várias instruções de máquina. Quando você examina a memória passo a passo, as instruções que encontra são diferentes daquelas do programa-fonte. A correspondência entre os dois conjuntos de instruções é muito simples, pois o SPIM não reorganiza as instruções para preencher delay slots.
Ordem de bytes Os processadores podem numerar os bytes dentro de uma palavra de modo que o byte com o número mais baixo seja o mais à esquerda ou à direita. A convenção
usada por uma máquina é considerada sua ordem de bytes. Os processadores MIPS podem operar com a ordem de bytes big-endian ou little-endian. Por exemplo, em uma máquina big-endian, a diretiva .byte 0, 1, 2, 3 resultaria em uma palavra de memória contendo Byte # 0
1
2
3
enquanto, em uma máquina little-endian, a palavra seria Byte # 3
2
1
0
O SPIM opera com duas ordens de bytes. A ordem de bytes do SPIM é a mesma ordem de bytes da máquina utilizada para executar o simulador. Por exemplo, em um Intel 80x86, o SPIM é little-endian, enquanto em um Macintosh ou Sun SPARC, o SPIM é big-endian.
Chamadas ao sistema O SPIM oferece um pequeno conjunto de serviços semelhantes aos oferecidos pelo sistema operacional, por meio da instrução de chamada ao sistema (syscall). Para requisitar um serviço, um programa carrega o código da chamada ao sistema (veja Figura A.9.1) no registrador $v0 e os argumentos nos registradores $a0–$a3 (ou $f12, para valores de ponto flutuante). As chamadas ao sistema que retornam valores colocam seus resultados no registrador $v0 (ou $f0 para resultados de ponto flutuante). Por exemplo, o código a seguir exibe “the answer = 5”:
FIGURA A.9.1 Serviços do sistema.
A chamada ao sistema print_int recebe um inteiro e o exibe no console. print_float exibe um único número de ponto flutuante; print_double exibe um número de precisão dupla; e print_string recebe um ponteiro para uma string terminada em nulo, que ele escreve no console. As chamadas ao sistema read_int, read_float e read_double leem uma linha inteira da entrada, até o caractere de newline, inclusive. Os caracteres após o número são ignorados. read_string possui a mesma semântica da rotina de biblioteca fgets do UNIX. Ela lê até n – 1 caracteres para um buffer e termina a string com um byte nulo. Se menos de n – 1 caracteres estiverem na linha atual, read_string lê até o caractere de newline, inclusive, e novamente termina a string com nulo. Aviso: os programas que usam essas syscalls para ler do terminal não deverão usar E/S mapeada em memória (ver Seção A.8). sbrk retorna um ponteiro para um bloco de memória contendo n bytes adicionais. exit interrompe o programa que o SPIM estiver executando. exit2 termina o programa SPIM, e o argumento de exit2 torna-se o valor retornado quando o próprio simulador SPIM termina. print_char e read_char escrevem e leem um único caractere, respectivamente. open, read, write e close são as chamadas da biblioteca padrão do UNIX.
A.10. Assembly do MIPS R2000 Um processador MIPS consiste em uma unidade de processamento de inteiros (a CPU) e uma coleção de coprocessadores que realizam tarefas auxiliares ou operam sobre outros tipos de dados, como números de ponto flutuante (veja Figura A.10.1). O SPIM simula dois coprocessadores. O coprocessador 0 trata de exceções e interrupções. O coprocessador 1 é a unidade de ponto flutuante. O SPIM simula a maior parte dos aspectos dessa unidade.
FIGURA A.10.1 CPU e FPU do MIPS R2000.
Modos de endereçamento O MIPS é uma arquitetura load-store, o que significa que somente instruções load e store acessam a memória. As instruções de cálculo operam apenas sobre os valores nos registradores. A máquina pura oferece apenas um modo de endereçamento de memória: c(rx), que usa a soma do c imediato e do
registrador rx como endereço. A máquina virtual oferece os seguintes modos de endereçamento para instruções load e store: Formato
Cálculo de endereço
(registrador)
conteúdo do registrador
imm
imediato
imm (registrador)
imediato + conteúdo do registrador
rótulo
endereço do rótulo
rótulo ± imediato
endereço do rótulo + ou – imediato
rótulo ± imediato (registrador) endereço do rótulo + ou – (imediato + conteúdo do registrador)
A maior parte das instruções load e store opera apenas sobre dados alinhados. Uma quantidade está alinhada se seu endereço de memória for um múltiplo do seu tamanho em bytes. Portanto, um objeto halfword precisa ser armazenado em endereços pares e um objeto palavra (word) precisa ser armazenado em endereços que são múltiplos de quatro. No entanto, o MIPS oferece algumas instruções para manipular dados não alinhados (lwl, lwr, swl e swr).
Detalhamento O assembler MIPS (e SPIM) sintetiza os modos de endereçamento mais complexos, produzindo uma ou mais instruções antes que o load ou o store calculem um endereço complexo. Por exemplo, suponha que o rótulo table referenciasse o local de memória 0x10000004 e um programa tivesse a instrução
O assembler traduziria essa instrução para as instruções
A primeira instrução carrega os bits mais significativos do endereço do rótulo no registrador $at, que é o registrador que o assembler reserva para seu próprio uso. A segunda instrução acrescenta o conteúdo do registrador $a1 ao endereço parcial do rótulo. Finalmente, a instrução load utiliza o modo de endereçamento de hardware para adicionar a soma dos bits menos significativos do endereço do rótulo e o offset da instrução original ao valor no registrador $at.
Sintaxe do assembler Os comentários nos arquivos do assembler começam com um sinal de sharp (#). Tudo desde esse sinal até o fim da linha é ignorado. Os identificadores são uma sequência de caracteres alfanuméricos, símbolos de underscore (_) e pontos (.), que não começam com um número. Os opcodes de instrução são palavras reservadas que não podem ser usadas como identificadores. Rótulos são declarados por sua colocação no início de uma linha e seguidos por um sinal de dois-pontos, por exemplo:
Os números estão na base 10 por padrão. Se eles forem precedidos por 0x, serão interpretados como hexadecimais. Logo, 256 e 0x100 indicam o mesmo valor. As strings são delimitadas com aspas (“). Caracteres especiais nas strings
seguem a convenção da linguagem C: ▪ nova linha \n ▪ tabulação \t ▪ aspas \” O SPIM admite um subconjunto das diretivas do assembler do MIPS: .align n
Alinha o próximo dado em um limite de 2n bytes. Por exemplo, .align 2 alinha o próximo valor em um limite da palavra. .align 0 desativa o alinhamento automático das diretivas .half, .word, .float e .double até a próxima diretiva .data ou .kdata.
.ascii str
Armazena a string str na memória, mas não a termina com nulo.
.asciiz str
Armazena a string str na memória e a termina com nulo.
.byte b1,..., bn
Armazena os n valores em bytes sucessivos da memória.
.data
Itens subsequentes são armazenados no segmento de dados. Se o argumento opcional end estiver presente, os itens subsequentes são armazenados a partir do endereço end.
.double d1,...,dn
Armazena os n números de precisão dupla em ponto flutuante em locais de memória sucessivos.
.extern sym tamanho
Declara que o dado armazenado em sym possui tamanho bytes de extensão e é um rótulo global. Essa diretiva permite que o assembler armazene o dado em uma parte do segmento de dados que é acessada eficientemente por meio do registrador $gp.
.float f1,..., fn
Armazena os n números de precisão simples em ponto flutuante em locais sucessivos na memória.
.globl sym
Declara que o rótulo sym é global e pode ser referenciado a partir de outros arquivos.
.half h1,..., hn
Armazena as n quantidades de 16 bits em halfwords sucessivas da memória.
.kdata< addr>
Itens de dados subsequentes são armazenados no segmento de dados do kernel. Se o argumento opcional addr estiver presente, itens subsequentes são armazenados a partir do endereço addr.
.ktext< addr>
Itens subsequentes são colocados no segmento de texto do kernel. No SPIM, esses itens só podem ser instruções ou palavras (ver a diretiva .word, mais adiante). Se o argumento opcional addr estiver presente, os itens subsequentes são armazenados a partir do endereço addr.
.set noat e .sat at
A primeira diretiva impede que o SPIM reclame sobre instruções subsequentes que utilizam o registrador $at. A segunda diretiva reativa a advertência. Como as pseudoinstruções se expandem para o código que usa o registrador $at, os programadores precisam ter muito cuidado ao deixar valores nesse registrador.
.space n
Aloca n bytes de espaço no segmento atual (que precisa ser o segmento de dados no SPIM).
.text < addr>
Itens subsequentes são colocados no segmento de texto do usuário. No SPIM, esses itens só podem ser instruções ou palavras (ver a diretiva .word a seguir). Se o argumento opcional addr estiver presente, os itens subsequentes são armazenados a partir do endereço addr.
.word w1,..., wn
Armazena as n quantidades de 32 bits em palavras de memória sucessivas.
O SPIM não distingue as várias partes do segmento de dados (.data, .rdata e .sdata).
Codificando instruções do MIPS
Codificando instruções do MIPS A Figura A.10.2 explica como uma instrução MIPS é codificada em um número binário. Cada coluna contém codificações de instrução para um campo (um grupo de bits contíguo) a partir de uma instrução. Os números na margem esquerda são valores para um campo. Por exemplo, o opcode j possui um valor 2 no campo opcode. O texto no topo de uma coluna nomeia um campo e especifica quais bits ele ocupa em uma instrução. Por exemplo, o campo op está contido nos bits 26-31 de uma instrução. Esse campo codifica a maioria das instruções. No entanto, alguns grupos de instruções utilizam campos adicionais para distinguir instruções relacionadas. Por exemplo, as diferentes instruções de ponto flutuante são especificadas pelos bits 0-5. As setas a partir da primeira coluna mostram quais opcodes utilizam esses campos adicionais.
FIGURA A.10.2 Mapa de opcode do MIPS. Os valores de cada campo aparecem à sua esquerda. A primeira coluna mostra os valores na base 10 e a segunda mostra a base 16 para o campo op (bits 31 a 26) na terceira coluna. Esse campo op especifica completamente a operação do MIPS, exceto para 6 valores de op: 0, 1, 16, 17, 18 e 19. Essas operações são determinadas pelos outros campos, identificados por ponteiros. O último campo (funct) utiliza “f” para indicar “s” se
rs = 16 e op = 17 ou “d” se rs = 17 e op = 17. O segundo campo (rs) usa “z” para indicar “0”, “1”, “2” ou “3” se op = 16, 17, 18 ou 19, respectivamente. Se rs = 16, a operação é especificada em outro lugar: se z = 0, as operações são especificadas no quarto campo (bits 4 a 0); se z = 1, então as operações são no último campo com f =s. Se rs = 17 e z = 1, então as operações estão no último campo com f = d.
Formato de instrução O restante deste apêndice descreve as instruções implementadas pelo hardware MIPS real e as pseudoinstruções fornecidas pelo assembler MIPS. Os dois tipos de instruções podem ser distinguidos facilmente. As instruções reais indicam os campos em sua representação binária. Por exemplo, em: Adição (com overflow)
a instrução add consiste em seis campos. O tamanho de cada campo em bits é o pequeno número abaixo do campo. Essa instrução começa com 6 bits em 0. Os especificadores de registradores começam com um r, de modo que o próximo campo é um especificador de registrador de 5 bits chamado rs. Esse é o mesmo registrador que é o segundo argumento no assembly simbólico à esquerda dessa linha. Outro campo comum é imm16, que é um número imediato de 16 bits. As pseudoinstruções seguem aproximadamente as mesmas convenções, mas omitem a informação de codificação de instrução. Por exemplo: Multiplicação (sem overflow) mul rdest, rsrc1, src2 pseudoinstrução Nas pseudoinstruções, rdest e rsrcl são registradores e src2 é um registrador ou um valor imediato. Em geral, o assembler e o SPIM traduzem uma forma mais geral de uma instrução (por exemplo, add $v1, $a0, 0x55) para uma forma especializada (por exemplo, addi $v1, $a0, 0x55).
Instruções aritméticas e lógicas Valor absoluto abs rdest, rsrc pseudoinstrução
Coloca o valor absoluto do registrador rsrc no registrador rdest. Adição (com overflow)
Adição (sem overflow)
Coloca a soma dos registradores rs e rt no registrador rd. Adição imediato (com overflow)
Adição imediato (sem overflow)
Coloca a soma do registrador rs e o imediato com sinal estendido no registrador rt. AND
Coloca o AND lógico dos registradores rs e rt no registrador rd. AND imediato
Coloca o AND lógico do registrador rs e o imediato estendido com zeros no registrador rt. Contar uns iniciais
Contar zeros iniciais
Conta o número de uns (zeros) iniciais da palavra no registrador rs e coloca o resultado no registrador rd. Se uma palavra contém apenas uns (zeros), o
resultado é 32. Divisão (com overflow)
Divisão (sem overflow)
Divide o registrador rs pelo registrador rt. Deixa o quociente no registrador lo e o resto no registrador hi. Observe que, se um operando for negativo, o restante não será especificado pela arquitetura MIPS e dependerá da convenção da máquina em que o SPIM é executado. Divisão (com overflow) div rdest, rsrc1, src2 pseudoinstrução
Divisão (sem overflow) divu rdest, rsrc1, src2 pseudoinstrução Coloca o quociente do registrador rsrcl pelo src2 no registrador rdest.
Multiplicação
Multiplicação sem sinal
Multiplica os registradores rs e rt. Deixa a palavra menos significativa do produto no registrador lo e a palavra mais significativa no registrador hi. Multiplicação (sem overflow)
Coloca os 32 bits menos significativos do produto de rs e rt no registrador rd. Multiplicação (com overflow) mulo rdest, rsrc1, src2 pseudoinstrução Multiplicação sem sinal (com overflow) mulou rdest, rsrc1, src2 pseudoinstrução Coloca os 32 bits menos significativos do produto do registrador rsrc1 e src2 no registrador rdest. Multiplicação adição
Multiplicação adição sem sinal
Multiplica os registradores rs e rt e soma o produto de 64 bits resultante ao valor de 64 bits nos registradores concatenados lo e hi. Multiplicação subtração
Multiplicação subtração sem sinal
Multiplica os registradores rs e rt e subtrai o produto de 64 bits resultante do valor de 64 bits nos registradores concatenados lo e hi. Negar valor (com overflow) neg rdest, rsrc pseudoinstrução Negar valor (sem overflow) negu rdest, rsrc pseudoinstrução Coloca o negativo do registrador rsrc no registrador rdest. NOR
Coloca o NOR lógico dos registradores rs e rt para o registrador rd. NOT
NOT not rdest, rsrc pseudoinstrução
Coloca a negação lógica bit a bit do registrador rsrc no registrador rdest. OR
Coloca o OR lógico dos registradores rs e rt no registrador rd. OR imediato
Coloca o OR lógico do registrador rs e o imediato estendido com zero no registrador rt. Resto rem rdest, rsrc1, rsrc2 pseudoinstrução
Resto sem sinal remu rdest, rsrc1, rsrc2 pseudoinstrução Coloca o resto do registrador rsrc1 dividido pelo registrador rsrc2 no registrador rdest. Observe que se um operando for negativo, o resto não é
especificado pela arquitetura MIPS e depende da convenção da máquina em que o SPIM é executado. Shift lógico à esquerda
Shift lógico à esquerda variável
Shift aritmético à direita
Shift aritmético à direita variável
Shift lógico à direita
Shift lógico à direita variável
Desloca o registrador rt à esquerda (direita) pela distância indicada pelo shamt imediato ou pelo registrador rs e coloca o resultado no registrador rd. Observe que o argumento rs é ignorado para sll, sra e srl. Rotate à esquerda rol rdest, rsrc1, rsrc2 pseudoinstrução
Rotate à direita ror rdest, rsrc1, rsrc2 pseudoinstrução Gira o registrador rsrc1 à esquerda (direita) pela distância indicada por rsrc2 e coloca o resultado no registrador rdest.
Subtração (com overflow)
Subtração (sem overflow)
Coloca a diferença dos registradores rs e rt no registrador rd. OR exclusivo
Coloca o XOR lógico dos registradores rs e rt no registrador rd. XOR imediato
Coloca o XOR lógico do registrador rs e o imediato estendido com zeros no registrador rt.
Instruções para manipulação de constantes Load superior imediato
Carrega a halfword menos significativa do imediato imm na halfword mais significativa do registrador rt. Os bits menos significativos do registrador são colocados em 0. Load imediato li rdest, imm pseudoinstrução Move o imediato imm para o registrador rdest.
Instruções de comparação Set se menor que
Set se menor que sem sinal
Coloca o registrador rd em 1 se o registrador rs for menor que rt; caso contrário, coloca-o em 0. Set se menor que imediato
Set se menor que imediato sem sinal
Coloca o registrador rt em 1 se o registrador rs for menor que o imediato estendido com sinal, e em 0 em caso contrário. Set se igual seq rdest, rsrc1, rsrc2 pseudoinstrução Coloca o registrador rdest em 1 se o registrador rsrcl for igual a rsrc2, e
em 0 caso contrário. Set se maior ou igual sge rdest, rsrc1, rsrc2 pseudoinstrução
Set se maior ou igual sem sinal sgeu rdest, rsrc1, rsrc2 pseudoinstrução Coloca o registrador rdest em 1 se o registrador rsrc1 for maior ou igual a rsrc2, e em 0 caso contrário. Set se maior que
Set se maior que sgt rdest, rsrc1, rsrc2 pseudoinstrução
Set se maior que sem sinal sgtu rdest, rsrc1, rsrc2 pseudoinstrução Coloca o registrador rdest em 1 se o registrador rsrc1 for maior que rsrc2, e em 0 caso contrário. Set se menor ou igual sle rdest, rsrc1, rsrc2 pseudoinstrução
Set se menor ou igual sem sinal sleu rdest, rsrc1, rsrc2 pseudoinstrução Coloca o registrador rdest em 1 se o registrador rsrc1 for menor ou igual a rsrc2, e em 0 caso contrário. Set se diferente sne rdest, rsrc1, rsrc2 pseudoinstrução Coloca o registrador rdest em 1 se o registrador rsrc1 não for igual a rsrc2,
e em 0 caso contrário.
Instruções de desvio As instruções de desvio utilizam um campo offset de instrução de 16 bits com sinal; logo, elas podem desviar 215 – 1 instruções (não bytes) para frente ou 215 instruções para trás. A instrução jump contém um campo de endereço de 26 bits. Em processadores MIPS reais, as instruções de desvio são delayed branches, que não transferem o controle até que a instrução após o desvio (seu “delay slot”) tenha sido executada (veja Capítulo 4). Os delayed branches afetam o cálculo de offset, pois precisam ser calculados em relação ao endereço da instrução do delay slot (PC + 4), que é quando o desvio ocorre. O SPIM não simula esse delay slot, a menos que os flags -bare ou -delayed_branch sejam especificados. No código assembly, os offsets normalmente não são especificados como números. Em vez disso, uma instrução desvia para um rótulo, e o assembler calcula a distância entre o desvio e a instrução destino. No MIPS-32, todas as instruções de desvio condicional reais (não pseudo) têm uma variante “provável” (por exemplo, a variável provável de beq é beql), que não executa a instrução no delay slot do desvio se o desvio não for tomado. Não
use essas instruções; elas poderão ser removidas em versões subsequentes da arquitetura. O SPIM implementa essas instruções, mas elas não são descritas daqui por diante. Branch b label pseudoinstrução
Desvia incondicionalmente para a instrução no rótulo (label). Branch coprocessador falso
Branch coprocessador verdadeiro
Desvia condicionalmente pelo número de instruções especificado pelo offset se o flag de condição de ponto flutuante numerado como cc for falso (verdadeiro). Se cc for omitido da instrução, o flag de código de condição 0 é assumido. Branch se for igual
Desvia condicionalmente pelo número de instruções especificado pelo offset se o registrador rs for igual a rt. Branch se for maior ou igual a zero
Desvia condicionalmente pelo número de instruções especificado pelo offset se o registrador rs for maior ou igual a 0. Branch se for maior ou igual a zero e link
Desvia condicionalmente pelo número de instruções especificado pelo offset se o registrador rs for maior ou igual a 0. Salva o endereço da próxima instrução no registrador 31. Branch se for maior que zero
Desvia condicionalmente pelo número de instruções especificado pelo offset se o registrador rs for maior que 0. Branch se for menor ou igual a zero
Desvia condicionalmente pelo número de instruções especificado pelo offset se o registrador rs for menor ou igual a 0. Branch se for menor e link
Desvia condicionalmente pelo número de instruções especificado pelo offset se o registrador rs for menor que 0. Salva o endereço da próxima instrução no registrador 31. Branch se for menor que zero
Desvia condicionalmente pelo número de instruções especificado pelo offset se o registrador rs for menor que 0. Branch se for diferente
Desvia condicionalmente pelo número de instruções especificado pelo offset se o registrador rs não for igual a rt. Branch se for igual a zero beqz rsrc, label pseudoinstrução Desvia condicionalmente para a instrução no rótulo se rsrc for igual a 0. Branch se for maior ou igual bge rsrc1, rsrc2, label pseudoinstrução Branch se for maior ou igual sem sinal bgeu rsrc1, rsrc2, label pseudoinstrução Desvia condicionalmente até a instrução no rótulo se o registrador rsrc1 for maior ou igual a rsrc2.
Branch se for maior bgt rsrc1, src2, label pseudoinstrução
Branch se for maior sem sinal bgtu rsrc1, src2, label pseudoinstrução Desvia condicionalmente para a instrução no rótulo se o registrador rsrc1 for maior do que src2. Branch se for menor ou igual ble rsrc1, src2, label pseudoinstrução Branch se for menor ou igual sem sinal bleu rsrc1, src2, label pseudoinstrução Desvia condicionalmente para a instrução no rótulo se o registrador rsrc1 for menor ou igual a src2. Branch se for menor blt rsrc1, rsrc2, label pseudoinstrução
Branch se for menor sem sinal bltu rsrc1, rsrc2, label pseudoinstrução Desvia condicionalmente para a instrução no rótulo se o registrador rsrc1 for menor do que src2. Branch se não for igual a zero bnez rsrc, label pseudoinstrução Desvia condicionalmente para a instrução no rótulo se o registrador rsrc não for igual a 0.
Instruções de jump Jump
Desvia incondicionalmente para a instrução no destino. Jump-and-link
Desvia incondicionalmente para a instrução no destino. Salva o endereço da próxima instrução no registrador $ra. Jump-and-link registrador
Desvia incondicionalmente para a instrução cujo endereço está no registrador rs. Salva o endereço da próxima instrução no registrador rd (cujo default é 31). Jump registrador
Desvia incondicionalmente para a instrução cujo endereço está no registrador rs.
Instruções de trap Trap se for igual
Se o registrador rs for igual ao registrador rt, gera uma exceção de Trap. Trap se for igual imediato
Se o registrador rs for igual ao valor de imm com sinal estendido, gera uma exceção de Trap. Trap se não for igual
Se o registrador rs não for igual ao registrador rt, gera uma exceção de Trap. Trap se não for igual imediato
Se o registrador rs não for igual ao valor de imm com sinal estendido, gera uma exceção de Trap. Trap se for maior ou igual
Trap sem sinal se for maior ou igual
Se o registrador rs for maior ou igual ao registrador rt, gera uma exceção de Trap. Trap se for maior ou igual imediato
Trap sem sinal se for maior ou igual imediato
Se o registrador rs for maior ou igual ao valor de imm com sinal estendido, gera uma exceção de Trap. Trap se for menor
Trap sem sinal se for menor
Se o registrador rs for menor que o registrador rt, gera uma exceção de Trap. Trap se for menor imediato
Trap sem sinal se for menor imediato
Se o registrador rs for menor do que o valor de imm com sinal estendido, gera uma exceção de Trap.
Instruções load Load endereço la rdest, endereço pseudoinstrução
Carrega o endereço calculado — não o conteúdo do local — para o
registrador rdest. Load byte
Load byte sem sinal
Carrega o byte no endereço para o registrador rt. O byte tem sinal estendido por lb, mas não por lbu. Load halfword
Load halfword sem sinal
Carrega a quantidade de 16 bits (halfword) no endereço para o registrador rt. A halfword tem sinal estendido por lh, mas não por lhu. Load word
Load word
Carrega a quantidade de 32 bits (palavra) no endereço para o registrador rt. Load word coprocessador 1
Carrega a palavra no endereço para o registrador ft da unidade de ponto flutuante. Load word à esquerda
Load word à direita
Carrega os bytes da esquerda (direita) da palavra do endereço possivelmente não alinhado para o registrador rt. Load doubleword ld rdest, endereço pseudoinstrução
Carrega a quantidade de 64 bits no endereço para os registradores rdest e rdest + 1. Load halfword não alinhada ulh rdest, endereço pseudoinstrução Load halfword sem sinal não alinhada ulhu rdest, endereço pseudoinstrução Carrega a quantidade de 16 bits (halfword) no endereço possivelmente não alinhado para o registrador rdest. A halfword tem extensão de sinal por ulh, mas não ulhu. Load word não alinhada ulw rdest, endereço pseudoinstrução Carrega a quantidade de 32 bits (palavra) no endereço possivelmente não alinhado para o registrador rdest. Load Linked
Carrega a quantidade de 32 bits (palavra) no endereço para o registrador rt e inicia uma operação ler-modificar-escrever indivisível. Essa operação é concluída por uma instrução de store condicional (sc), que falhará se outro processador escrever no bloco que contém a palavra carregada. Como o SPIM não simula processadores múltiplos, a operação de store condicional sempre tem sucesso.
Instruções store Store byte
Armazena o byte baixo do registrador rt no endereço. Store halfword
Armazena a halfword baixa do registrador rt no endereço. Store word
Armazena a palavra do registrador rt no endereço. Store word coprocessador 1
Armazena o valor de ponto flutuante no registrador ft do coprocessador de ponto flutuante no endereço. Store double coprocessador 1
Armazena o valor de ponto flutuante da dupla palavra nos registradores ft e ft + 1 do coprocessador de ponto flutuante em endereço. O registrador ft precisa ser um número par. Store word à esquerda
Store word à direita
Armazena os bytes da esquerda (direita) do registrador possivelmente não alinhado.
rt
no endereço
Store doubleword sd rsrc, endereço pseudoinstrução Armazena a quantidade de 64 bits nos registradores rsrc e rsrc + 1 no endereço. Store halfword não alinhada ush rsrc, endereço pseudoinstrução Armazena a halfword baixa do registrador rsrc no endereço possivelmente não alinhado. Store word não alinhada usw rsrc, endereço pseudoinstrução
Armazena a palavra do registrador rsrc no endereço possivelmente não alinhado. Store condicional
Armazena a quantidade de 32 bits (palavra) no endereço rt para a memória no endereço e completa uma operação ler-modificar-escrever indivisível. Se essa operação indivisível tiver sucesso, a palavra da memória será modificada e o registrador rt será colocado em 1. Se a operação indivisível falhar porque outro processador escreveu em um local no bloco contendo a palavra endereçada, essa instrução não modifica a memória e escreve 0 no registrador rt. Como o SPIM não simula diversos processadores, a instrução sempre tem sucesso.
Instruções para movimentação de dados Move move rdest, rsrc pseudoinstrução Move o registrador rsrc para rdest.
Move de hi
Move de lo
A unidade de multiplicação e divisão produz seu resultado em dois registradores adicionais, hi e lo. Essas instruções movem os valores de, e para, esses registradores. As pseudoinstruções de multiplicação, divisão e resto que fazem com que essa unidade pareça operar sobre os registradores gerais movem o resultado depois que o cálculo terminar. Move o registrador hi (lo) para o registrador rd. Move para hi
Move para lo
Move o registrador rs para o registrador hi (lo). Move do coprocessador 0
Move do coprocessador 1
Os coprocessadores têm seus próprios conjuntos de registradores. Essas instruções movem valores entre esses registradores e os registradores da CPU. Move o registrador rd em um coprocessador (registrador fs na FPU) para o registrador rt da CPU. A unidade de ponto flutuante é o coprocessador 1. Move double do coprocessador 1 mfc1.d rdest, frsrc1 pseudoinstrução Move os registradores de ponto flutuante frsc1 e registradores da CPU rdest e rdest + 1.
frsrc1 + 1
para os
Move para coprocessador 0
Move para coprocessador 1
Move o registrador da CPU rt para o registrador rd em um coprocessador (registrador fs na FPU). Move condicional diferente de zero
Move o registrador rs para o registrador rd se o registrador rt não for 0. Move condicional zero
Move o registrador rs para o registrador rd se o registrador rt for 0. Move condicional em caso de FP falso
Move o registrador da CPU rs para o registrador rd se o flag de código de condição da FPU número cc for 0. Se cc for omitido da instrução, o flag de código de condição 0 será assumido. Move condicional em caso de FP verdadeiro
Move o registrador da CPU rs para o registrador rd se o flag de código de condição da FPU número cc for 1. Se cc for omitido da instrução, o bit de código de condição 0 é assumido.
Instruções de ponto flutuante O MIPS possui um coprocessador de ponto flutuante (número 1) que opera sobre números de ponto flutuante de precisão simples (32 bits) e precisão dupla (64 bits). Esse coprocessador tem seus próprios registradores, que são numerados de
$f0 a $f31. Como esses registradores possuem apenas 32 bits, dois deles são
necessários para manter doubles, de modo que somente registradores de ponto flutuante com números pares podem manter valores de precisão dupla. O coprocessador de ponto flutuante também possui 8 flags de código de condição (cc), numerados de 0 a 7, que são alterados por instruções de comparação e testados por instruções de desvio (bclf ou bclt) e instruções move condicionais. Os valores são movidos para dentro e para fora desses registradores uma palavra (32 bits) de cada vez pelas instruções lwc1, swc1, mtc1 e mfc1 ou um double (64 bits) de cada vez por ldc1 e sdc1, descritos anteriormente, ou pela pseudoinstruções l.s, l.d, s.s e s.d, descritas a seguir. Nas instruções reais a seguir, os bits 21-26 são 0 para precisão simples e 1 para precisão double. Nas pseudoinstruções a seguir, fdest é um registrador de ponto flutuante (por exemplo, $f2). Valor absoluto de ponto flutuante double
Valor absoluto de ponto flutuante single
Calcula o valor absoluto do double (single) de ponto flutuante no registrador fs e o coloca no registrador fd. Adição de ponto flutuante double
Adição de ponto flutuante single
Calcula a soma dos doubles (singles) de ponto flutuante nos registradores fs e ft e a coloca no registrador fd. Teto de ponto flutuante para word
Calcula o teto do double (single) de ponto flutuante no registrador fs, converte para um valor de ponto fixo de 32 bits e coloca a palavra resultante no registrador fd. Comparação igual double
Comparação igual single
Compara o double (single) de ponto flutuante no registrador fs com aquele em
ft e coloca o flag de condição de ponto flutuante cc em 1 se forem iguais. Se cc
for omitido, o flag de código de condição 0 é assumido. Comparação menor ou igual double
Comparação menor ou igual single
Compara o double (single) de ponto flutuante no registrador fs com aquele no ft e coloca o flag de condição de ponto flutuante cc em 1 se o primeiro for menor ou igual ao segundo. Se o cc for omitido, o flag de código de condição 0 é assumido. Comparação menor que double
Comparação menor que single
Compara o double (single) de ponto flutuante no registrador fs com aquele no ft e coloca o flag de condição de ponto flutuante cc em 1 se o primeiro for
menor que o segundo. Se o cc for omitido, o flag de código de condição 0 é assumido. Converte single para double
Converte integer para double
Converte o número de ponto flutuante de precisão simples ou inteiro no registrador fs para um número de precisão dupla (simples) e o coloca no registrador fd. Converte double para single
Converte integer para single
Converte o número de ponto flutuante de precisão dupla ou inteiro no registrador fs para um número de precisão simples e o coloca no registrador fd.
Converte double para integer
Converte single para integer
Converte o número de ponto flutuante de precisão dupla ou simples no registrador fs para um inteiro e o coloca no registrador fd. Divisão de ponto flutuante double
Divisão de ponto flutuante single
Calcula o quociente dos números de ponto flutuante de precisão dupla (simples) nos registradores fs e ft e o coloca no registrador fd. Piso de ponto flutuante para palavra
Calcula o piso do número de ponto flutuante de precisão dupla (simples) no registrador fs e coloca a palavra resultante no registrador fd. Carrega double de ponto flutuante l.d fdest, endereço pseudoinstrução Carrega single de ponto flutuante l.s fdest, endereço pseudoinstrução Carrega o número de ponto flutuante de precisão dupla (simples) em endereço para o registrador fdest. Move ponto flutuante double
Move ponto flutuante single
Move o número de ponto flutuante de precisão dupla (simples) do registrador fs para o registrador fd. Move condicional de ponto flutuante double se falso
Move condicional de ponto flutuante single se falso
Move o número de ponto flutuante de precisão dupla (simples) do registrador fs para o registrador fd se o flag do código de condição cc for 0. Se o cc for omitido, o flag de código de condição 0 é assumido. Move condicional de ponto flutuante double se verdadeiro
Move condicional de ponto flutuante single se verdadeiro
Move o double (single) de ponto flutuante do registrador fs para o registrador fd se o flag do código de condição cc for 1. Se o cc for omitido, o flag do código de condição 0 será assumido. Move ponto flutuante double condicional se não for zero
Move ponto flutuante single condicional se não for zero
Move o número de ponto flutuante double (single) do registrador fs para o registrador fd se o registrador rt do processador não for 0. Move ponto flutuante double condicional se for zero
Move ponto flutuante single condicional se for zero
Move o número de ponto flutuante double (single) do registrador fs para o registrador fd se o registrador rt do processador for 0. Multiplicação de ponto flutuante double
Multiplicação de ponto flutuante single
Calcula o produto dos números de ponto flutuante double (single) nos registradores fs e ft e o coloca no registrador fd. Negação double
Negação single
Nega o número de ponto flutuante double (single) no registrador fs e o coloca no registrador fd. Arredondamento de ponto flutuante para palavra
Arredonda o valor de ponto flutuante double (single) no registrador fs, converte para um valor de ponto fixo de 32 bits e coloca a palavra resultante no registrador fd. Raiz quadrada de double
Raiz quadrada de double
Raiz quadrada de single
Calcula a raiz quadrada do número de ponto flutuante double (single) no registrador fs e a coloca no registrador fd. Store de ponto flutuante double s.d fdest, endereço pseudoinstrução Store de ponto flutuante single s.s fdest, endereço pseudoinstrução Armazena o número de ponto flutuante double (single) no registrador fdest em endereço. Subtração de ponto flutuante double
Subtração de ponto flutuante single
Calcula a diferença dos números de ponto flutuante double (single) nos registradores fs e ft e a coloca no registrador fd. Truncamento de ponto flutuante para palavra
Trunca o valor de ponto flutuante double (single) no registrador fs, converte para um valor de ponto fixo de 32 bits e coloca a palavra resultante no registrador fd.
Instruções de exceção e interrupção Retorno de exceção
Coloca em 0 o bit EXL no registrador Status do coprocessador 0 e retorna à instrução apontada pelo registrador EPC do coprocessador 0. Chamada ao sistema
O registrador $v0 contém o número da chamada ao sistema (veja Figura A.9.1) fornecido pelo SPIM. Break
Causa a exceção código. A Exceção 1 é reservada para o depurador. Nop
Não faz nada.
A.11. Comentários finais A programação em assembly exige que um programador escolha entre os recursos úteis das linguagens de alto nível — como estruturas de dados, verificação de tipo e construções de controle — e o controle completo sobre as instruções que um computador executa. Restrições externas sobre algumas aplicações, como o tempo de resposta ou o tamanho do programa, exigem que um programador preste muita atenção a cada instrução. No entanto, o custo desse nível de atenção são programas em assembly maiores, mais demorados para escrever e mais difíceis de manter do que os programas em linguagem de alto nível. Além do mais, três tendências estão reduzindo a necessidade de escrever programas em linguagem assembly. A primeira tendência é em direção à melhoria dos compiladores. Os compiladores modernos produzem código comparável ao melhor código escrito manualmente — e, às vezes, melhor ainda. A segunda tendência é a introdução de novos processadores, que não apenas são mais rápidos, mas, no caso de processadores que executam várias instruções ao mesmo tempo, também são mais difíceis de programar manualmente. Além disso, a rápida evolução dos computadores modernos favorece os programas em linguagem de alto nível que não estejam presos a uma única arquitetura. Finalmente, temos testemunhado uma tendência em direção a aplicações cada vez mais complexas, caracterizadas por interfaces gráficas complexas e muito mais recursos do que seus predecessores. Grandes aplicações são escritas por equipes de programadores e exigem recursos de modularidade e verificação semântica fornecidos pelas linguagens de alto nível.
Leitura adicional Aho, A., R. Sethi e J. Ullman [1985]. Compilers: Principles, Techniques, and Tools, Reading, MA: Addison-Wesley. Ligeiramente desatualizado e faltando a cobertura das arquiteturas modernas, mas ainda é a referência padrão sobre compiladores. Sweetman, D. [1999]. See MIPS Run, San Francisco CA: Morgan Kaufmann Publishers. Uma introdução completa, detalhada e envolvente sobre o conjunto de instruções do MIPS e a programação em assembly nessas máquinas.
A documentação detalhada sobre a arquitetura MIPS-32 está disponível na Web: MIPS32™ Architecture for Programmers Volume I: Introduction to the MIPS32™ Architecture (http://mips.com/content/Documentation/MIPSDocumentation/ProcessorArchitecture/Arc 2B-MIPS32INT-AFP-02.00.pdf/getDownload) MIPS32™ Architecture for Programmers Volume II: The MIPS-32 Instruction Set (http://mips.com/content/Documentation/MIPSDocumentation/ProcessorArchitecture/Arc 2B-MIPS32BIS-AFP-02.00.pdf/getDownload) MIPS32™ Architecture for Programmers Volume III: The MIPS-32 Privileged Resource Architecture (http://mips.com/content/Documentation/MIPSDocumentation/ProcessorArchitecture/Arc 2B-MIPS32PRA-AFP-02.00.pdf/getDownload)
A.12. Exercícios A.1 [5] A Seção A.5 descreveu como a memória é dividida na maioria dos sistemas MIPS. Proponha outra maneira de dividir a memória, que cumpra os mesmos objetivos. A.2 [20] Reescreva o código para fact utilizando menos instruções. A.3 [5] É seguro que um programa do usuário utilize os registradores $k0 ou $k1? A.4 [25] A Seção A.7 contém código para um handler de exceção muito simples. Um problema sério com esse handler é que ele desativa as interrupções por um longo tempo. Isso significa que as interrupções de um dispositivo de E/S rápido podem ser perdidas. Escreva um handler de exceção melhor, que não possa ser interrompido mas que ative as interrupções o mais rápido possível. A.5 [15] O handler de exceção simples sempre desvia para a instrução após a exceção. Isso funciona bem, a menos que a instrução que causou a exceção esteja no delay slot de um desvio. Nesse caso, a próxima instrução será o alvo do desvio. Escreva um handler melhor, que use o registrador EPC para determinar qual instrução deverá ser executada após a exceção. A.6 [5] Usando o SPIM, escreva e teste um programa de calculadora que leia inteiros repetidamente e os adicione a um acumulador. O programa deverá parar quando receber uma entrada 0, exibindo a soma nesse ponto. Use as chamadas do sistema do SPIM descritas na Seção A.9. A.7 [5] Usando o SPIM, escreva e teste um programa que leia três inteiros e exiba a soma dos dois maiores desses três. Use as chamadas do sistema do SPIM descritas na Seção A.9. Você pode usar o critério de desempate que desejar. A.8 [5] Usando o SPIM, escreva e teste um programa que leia um inteiro positivo usando as chamadas do sistema do SPIM. Se o inteiro não for positivo, o programa deverá terminar com a mensagem “Entrada inválida”; caso contrário, o programa deverá exibir os nomes dos dígitos dos inteiros por extenso, delimitados por, exatamente, um espaço. Por exemplo, se o usuário informou “728”, a saída deverá ser “Sete Dois Oito”. A.9 [25] Escreva e teste um programa em assembly do MIPS para calcular e exibir os 100 primeiros números primos. Um número n é primo se ele só puder ser dividido exatamente por ele mesmo e por 1. Duas rotinas
deverão ser implementadas: ▪ testa_primo(n) retorna 1 se n for primo e 0 se n não for primo. ▪ main() percorre os inteiros, testando se cada um deles é primo. Exibe os 100 primeiros números primos. Teste seus programas executando-os no SPIM. A.10 [10] Usando o SPIM, escreva e teste um programa recursivo para solucionar um problema matemático clássico, denominado Torres de Hanói. (Isso exigirá o uso de frames de pilha para admitir a recursão.) O problema consiste em três pinos (1, 2 e 3) e n discos (o número n pode variar; os valores típicos poderiam estar no intervalo de 1 a 8). O disco 1 é menor que o disco 2, que, por sua vez, é menor que o disco 3, e assim por diante, com o disco n sendo o maior. Inicialmente, todos os discos estão no pino 1, começando com o disco n na parte inferior, o disco n – 1 acima dele, e assim por diante, até o disco 1 no topo. O objetivo é mover todos os discos para o pino 2. Você só pode mover um disco de cada vez, ou seja, o disco superior de qualquer um dos três pinos para o topo de qualquer um dos outros dois pinos. Além do mais, existe uma restrição: não é possível colocar um disco maior em cima de um disco menor. O programa em C a seguir pode ser usado como uma base para a escrita do seu programa em assembly:
1 Esta seção discute as exceções na arquitetura MIPS-32, que é o que o SPIM implementa na
Versão 7.0 em diante. As versões anteriores do SPIM implementaram a arquitetura MIPS-I, que tratava exceções de forma ligeiramente diferente. A conversão de programas a partir dessas versões para execução no MIPS-32 não deverá ser difícil, pois as mudanças são limitadas aos campos dos registradores Status e Cause e à substituição da instrução rfe pela instrução eret. 2 As primeiras versões do SPIM (antes da 7.0) implementaram a arquitetura MIPS-1 utilizada
nos processadores MIPS R2000 originais. Essa arquitetura é quase um subconjunto apropriado da arquitetura MIPS-32, sendo que a diferença é a maneira como as exceções são tratadas. O MIPS-32 também introduziu aproximadamente 60 novas instruções, que são aceitas pelo SPIM. Os programas executados nas versões anteriores do SPIM e que não usavam exceções deverão ser executados sem modificação nas versões mais recentes do SPIM. Os programas que usavam exceções exigirão pequenas mudanças.
APÊNDICE B
Fundamentos do Projeto Lógico Eu sempre gostei dessa palavra: Boolean. Claude Shannon IEEE Spectrum, abril de 1992 (A tese do professor Shannon mostrou que a álgebra inventada por George Boole no século XIX poderia representar o funcionamento das chaves elétricas.)
B.1 Introdução B.2 Portas, tabelas verdade e equações lógicas B.3 Lógica combinacional B.4 Usando uma linguagem de descrição de hardware B.5 Construindo uma unidade lógica e aritmética básica B.6 Adição mais rápida: Carry Lookahead B.7 Clocks B.8 Elementos de memória: Flip-flops, latches e registradores B.9 Elementos de memória: SRAMs e DRAMs B.10 Máquinas de estados finitos B.11 Metodologias de temporização B.12 Dispositivos programáveis em campo B.13 Comentários finais B.14 Exercícios
B.1. Introdução Este apêndice oferece uma rápida discussão sobre os fundamentos do projeto lógico. Ele não substitui um curso sobre projeto lógico nem permitirá projetar sistemas lógicos funcionais significativos. Contudo, se você tiver pouca ou nenhuma experiência com projeto lógico, este apêndice oferecerá uma base suficiente para entender todo o material deste livro. Além disso, se você quiser entender parte da motivação por trás da forma como os computadores são implementados, este material servirá como uma introdução útil. Se a sua curiosidade for aumentada, mas não saciada por este apêndice, as referências ao final oferecem fontes de informação adicionais. A Seção B.2 introduz os blocos de montagem básicos da lógica, a saber, portas lógicas. A Seção B.3 utiliza esses blocos de montagem para construir sistemas lógicos combinacionais simples, que não contêm memória. Se você já teve alguma experiência com sistemas lógicos ou digitais, provavelmente estará acostumado com o material dessas duas primeiras seções. A Seção B.5 mostra como usar os conceitos das Seções B.2 e B.3 para projetar uma ALU para o processador MIPS. A Seção B.6 mostra como criar um somador rápido e pode ser pulada sem problemas se você não estiver interessado neste assunto. A Seção B.7 é uma introdução rápida ao assunto de clocking, necessário para discutirmos como funcionam os elementos de memória. A Seção B.8 introduz os elementos de memória, e a Seção B.9 a estende para focalizar em memórias de acesso aleatório; ele descreve as características importantes para entender como são usadas, conforme discutimos no Capítulo 4 e a base que motiva muitos dos aspectos do projeto de hierarquia de memória no Capítulo 5. A Seção B.10 descreve o projeto e o uso das máquinas de estados finitos, que são blocos lógicos sequenciais. Se você pretende ler apenas o material sobre controle, no Capítulo 4, poderá passar superficialmente pelos apêndices, mas deverá ter alguma familiaridade com todo o material, exceto a Seção B.11, pois ela serve para aqueles que desejam ter um conhecimento mais profundo das metodologias de temporização. Ela explica os fundamentos de como funciona o clock acionado por transição, introduz outro esquema de temporização e descreve rapidamente o problema de sincronizar entradas assíncronas. Ao longo do apêndice, onde for apropriado, também incluímos segmentos em Verilog para demonstrar como a lógica pode ser representada em Verilog, que apresentaremos na Seção B.4.
B.2. Portas, tabelas verdade e equações lógicas A eletrônica dentro de um computador moderno é digital e opera apenas com dois níveis de tensão: alta e baixa. Todos os outros valores de tensão são temporários e ocorrem na transição entre os valores. (Conforme discutimos mais adiante nesta seção, uma armadilha possível no projeto digital é a amostragem de um sinal quando ele não é nitidamente alto ou baixo.) O fato de que os computadores são digitais também é um motivo fundamental para eles usarem números binários, pois um sistema binário corresponde à abstração básica inerente à eletrônica. Em diversas famílias lógicas, os valores e os relacionamentos entre os dois valores de tensão diferem. Assim, em vez de se referir aos níveis de tensão, falamos sobre sinais que são (logicamente) verdadeiros, 1, ativos; ou sinais que são (logicamente) falsos, 0, inativos. Os valores 0 e 1 são chamados complementos ou inversos um do outro.
sinal ativo Um sinal que é (logicamente) verdadeiro ou 1.
sinal inativo Um sinal que é (logicamente) falso ou 0. Os blocos lógicos são categorizados como um dentre dois tipos, dependendo se contêm memória ou não. Os blocos sem memória são chamados combinacionais; a saída de um bloco combinacional depende apenas da entrada atual. Nos blocos com memória, as saídas podem depender das entradas e do valor armazenado na memória, chamado estado do bloco lógico. Nesta seção e na seguinte, focalizaremos apenas a lógica combinacional. Depois de introduzir diferentes elementos de memória na Seção B.8, descreveremos como é projetada a lógica sequencial, que é a lógica que inclui estado.
lógica combinacional Um sistema lógico cujos blocos não contêm memória e, portanto, calculam a
mesma saída dada à mesma entrada.
lógica sequencial Um grupo de elementos lógicos que contêm memória e cujo valor, portanto, depende das entradas e também do conteúdo atual da memória.
Tabelas verdade Como um bloco de lógica combinacional não contém memória, ele pode ser especificado completamente definindo os valores das saídas para cada conjunto de valores de entrada possíveis. Essa descrição normalmente é dada como uma tabela verdade. Para um bloco lógico com n entradas, existem 2n entradas na tabela verdade, pois existem todas essas combinações possíveis de valores de entrada. Cada entrada especifica o valor de todas as saídas para essa combinação de entrada em particular.
Tabelas verdade Exemplo Considere uma função lógica com três entradas, A, B e C, e três saídas, D, E e F. A função é definida da seguinte maneira: D é verdadeiro se pelo menos uma entrada for verdadeira, E é verdadeiro se exatamente duas entradas forem verdadeiras, e F é verdadeiro somente se todas as três entradas forem verdadeiras. Mostre a tabela verdade para essa função.
Resposta A tabela verdade terá 2n = 8 entradas. Aqui está ela: Entradas
Saídas
A
B
C
D
E
F
0
0
0
0
0
0
0
0
1
1
0
0
0
1
0
1
0
0
0
1
1
1
1
0
1
0
0
1
0
0
1
0
1
1
1
0
1
1
0
1
1
0
1
1
1
1
0
1
As tabelas verdade podem descrever qualquer função lógica combinacional; porém, elas aumentam de tamanho rapidamente e podem não ser fáceis de entender. Às vezes, queremos construir uma função lógica que será 0 para muitas combinações de entrada e usamos um atalho para especificar apenas as entradas da tabela verdade para as saídas diferentes de zero. Esta técnica é usada no Capítulo 4.
Álgebra Booleana Outra técnica é expressar a função lógica com equações lógicas. Isso é feito com o uso da álgebra Booleana (que tem o nome de Boole, um matemático do século XIX). Em álgebra Booleana, todas as variáveis possuem os valores 0 ou 1 e, nas formulações típicas, existem três operadores: ▪ O operador OR é escrito como +, como em A + B. O resultado de um operador OR é 1 se uma das variáveis for 1. A operação OR também é chamada soma lógica, pois seu resultado é 1 se um dos operandos for 1. ▪ O operador AND é escrito como ·, como em A · B. O resultado de um operador AND é 1 somente se as duas entradas forem 1. A operação AND também é chamada de produto lógico, pois seu resultado é 1 apenas se os dois operandos forem 1. ▪ O operador unário NOT é escrito como A—. O resultado de um operador NOT é 1 somente se a entrada for 0. A aplicação do operador NOT a um valor lógico resulta em uma inversão ou negação do valor (ou seja, se a entrada for 0, a saída será 1 e vice-versa). Existem sete leis da álgebra Booleana úteis na manipulação de equações lógicas: ▪ Lei da identidade: A + 0 = A e A · 1 = A. ▪ Leis de zero e um: A + 1 = 1 e A · 0 = 0. ▪ Leis inversas: A + A— = 1 e A · A— = 0. ▪ Leis comutativas: A + B = B + A e A · B = B · A. ▪ Leis associativas: A + (B + C) = (A + B) + C e A · (B · C) = (A · B) · B. ▪ Leis distributivas: A · (B + C) = (A · B) + (A · C) e A + (B · C) = (A + B) · (A + C). Além disso, existem dois outros teoremas úteis, chamados leis de DeMorgan,
que são discutidos com mais profundidade nos exercícios. Qualquer conjunto de funções lógicas pode ser escrito como uma série de equações com uma saída no lado esquerdo de cada equação e, no lado direito, uma fórmula consistindo em variáveis e os três operadores anteriores.
Equações lógicas Exemplo Mostre as equações lógicas para as funções lógicas, D, E e F, descritas no exemplo anterior.
Resposta Aqui está a equação para D:
F é igualmente simples:
E é um pouco complicada. Pense nela em duas partes: o que precisa ser verdadeiro para E ser verdadeiro (duas das três entradas precisam ser verdadeiras) e o que não pode ser verdadeiro (todas as três não podem ser verdadeiras). Assim, podemos escrever E como
Também podemos derivar E observando que E é verdadeiro apenas se exatamente duas das entradas forem verdadeiras. Então, podemos escrever E como um OR dos três termos possíveis que possuem duas entradas verdadeiras e uma entrada falsa:
A prova de que essas duas expressões são equivalentes é explorada nos exercícios. Em Verilog, descrevemos a lógica combinacional, sempre que possível, usando a instrução assign, descrita, a partir da página B-23. Podemos escrever uma definição para E usando o operador OR exclusivo da Verilog, como em assign E = (A ^ B ^ C) * (A + B + C) * (A * B * C), que é outra maneira de descrever essa função. D e F possuem representações ainda mais simples, que são exatamente como o código C correspondente: D = A | B | C e F = A & B & C.
Portas lógicas Blocos lógicos são criados a partir de portas lógicas que implementam as funções lógicas básicas. Por exemplo, uma porta AND implementa a função AND e uma porta OR implementa a função OR. Como AND e OR são comutativos e associativos, uma porta AND ou OR pode ter várias entradas, com a saída igual ao AND ou OR de todas as entradas. A função lógica NOT é implementada com um inversor que sempre possui uma única entrada. A representação padrão desses três blocos de montagem lógicos aparece na Figura B.2.1.
FIGURA B.2.1 Desenho padrão para uma porta AND, porta OR e um inversor, mostrados da esquerda para a direita. Os sinais à esquerda de cada símbolo são as entradas, enquanto a saída aparece à direita. As portas AND e OR possuem duas entradas. Os inversores possuem uma única entrada.
porta lógica Um dispositivo que implementa funções lógicas básicas, como AND ou OR.
Em vez de desenhar inversores explicitamente, uma prática comum é acrescentar “bolhas” às entradas ou saída de uma porta lógica para fazer com que o valor lógico nessa linha de entrada ou de saída seja invertida. Por exemplo, a Figura B.2.2 mostra o diagrama lógico para a função , usando inversores explícitos à esquerda e usando as entradas e a saída em bolha à direita.
FIGURA B.2.2 Implementação de porta lógica de usando inversões explícitas à esquerda e entradas e saídas em bolha à direita.A função lógica pode ser simplificada para A · B— ou, em Verilog, A & ∼ B.
Qualquer função lógica pode ser construída usando portas AND, portas OR e inversão; vários dos exercícios dão a oportunidade de tentar implementar algumas funções lógicas comuns com portas. Na próxima seção, veremos como uma implementação de qualquer função lógica pode ser construída usando esse conhecimento. De fato, todas as funções lógicas podem ser construídas com apenas um único tipo de porta lógica, se essa porta for inversora. As duas portas inversoras são chamadas NOR e NAND e correspondem às portas OR e AND invertidas, respectivamente. Portas NOR e NAND são chamadas universais, pois qualquer função lógica pode ser construída por meio desse tipo de porta. Os exercícios exploram melhor esse conceito.
Porta NOR Uma porta OR invertida.
Porta NAND Uma porta AND invertida.
Verifique você mesmo
Verifique você mesmo As duas expressões lógicas, a seguir, são equivalentes? Se não, encontre valores para as variáveis, mostrando que não são: ▪ ▪
B.3. Lógica combinacional Nesta seção, examinamos alguns dos maiores blocos de montagem lógicos mais utilizados e discutimos o projeto da lógica estruturada que pode ser implementado automaticamente, a partir de uma equação lógica ou tabela verdade por um programa de tradução. Por fim, discutimos a noção de um array de blocos lógicos.
Decodificadores Um bloco lógico que usaremos na montagem de componentes maiores é um decodificador. O tipo mais comum de decodificador possui uma entrada de n bits e 2n saídas, onde somente uma saída é ativada para cada combinação de entradas. Esse decodificador traduz a entrada de n bits para um sinal que corresponde ao valor binário da entrada de n bits. As saídas, portanto, são numeradas como, digamos, Out0, Out1, ..., Out2n – 1. Se o valor da entrada for i, então Outi será verdadeiro e todas as outras saídas serão falsas. A Figura B.3.1 mostra um decodificador de 3 bits e a tabela verdade. Esse decodificador é chamado decodificador 3 para 8, pois existem 3 entradas e 8 (23) saídas. Há também um elemento lógico chamado de codificador, que realiza a função inversa de um decodificador, exigindo 2n entradas e produzindo uma saída de n bits.
FIGURA B.3.1 Decodificador de 3 bits possui 3 entradas, chamadas 12, 11 e 10, e (23) = 8 saídas, chamadas de Out0 a Out7. Somente a saída correspondente ao valor binário da entrada é
verdadeira, como mostra a tabela verdade. O rótulo 3, na entrada do decodificador, diz que o sinal de entrada possui 3 bits de largura.
decodificador Um bloco lógico que possui uma entrada de n bits e 2n saídas, onde somente uma saída é ativada para cada combinação de entradas.
Multiplexadores Uma função lógica básica que usamos com muita frequência no Capítulo 4 é o multiplexador. Um multiplexador poderia ser denominado de seletor, pois sua saída é uma das entradas selecionada por um controle. Considere o multiplexador de duas entradas. O lado esquerdo da Figura B.3.2 mostra que esse multiplexador tem três entradas: dois valores de dados e um valor seletor (ou de controle). O valor seletor determina qual das entradas se torna a saída. Podemos representar a função lógica calculada por um multiplexador de duas entradas, mostrado em forma de portas lógicas no lado direito da Figura B.3.2, como C = (A · S) + (B · S).
FIGURA B.3.2 Um multiplexador de duas entradas, à esquerda, e sua implementação com portas lógicas, à direita. O multiplexador tem duas entradas de dados (A e B), que são rotuladas com 0 e 1, e uma entrada seletora (S), além de uma saída C. A implementação de multiplexadores em Verilog exige um pouco mais de trabalho, especialmente quando eles
possuem mais de duas entradas. Mostramos como fazer isso a partir da página B-23.
valor seletor Também chamado valor de controle. O sinal de controle usado para selecionar um dos valores de entrada de um multiplexador como a saída do multiplexador. Os multiplexadores podem ser criados com qualquer quantidade de entradas de dados. Quando existem apenas duas entradas, o seletor é um único sinal que seleciona uma das entradas se ela for verdadeira (1) e a outra se ela for falsa (0). Se houver n entradas de dados, terá de haver |log2n| entradas seletoras. Nesse caso, o multiplexador basicamente consiste em três partes: 1. Um decodificador que gera n sinais, cada um indicando um valor de entrada diferente 2. Um array de n portas AND, cada uma combinando com uma das entradas com um sinal do decodificador 3. Uma única porta OR grande, que incorpora as saídas das portas AND Para associar as entradas com valores do seletor, rotulamos as entradas de dados numericamente (ou seja, 0, 1, 2, 3, ..., n – 1) e interpretamos as entradas do seletor de dados como um número binário. Às vezes, utilizamos um multiplexador com sinais de seletor não decodificados. Os multiplexadores são representados combinacionalmente em Verilog usando expressões if. Para multiplexadores maiores, instruções case são mais convenientes, mas deve-se ter cuidado ao sintetizar a lógica combinacional.
Lógica de dois níveis e PLAs Conforme indicado na seção anterior, qualquer função lógica pode ser implementada apenas com as funções AND, OR e NOT. Na verdade, um resultado muito mais forte é verdadeiro. Qualquer função lógica pode ser escrita em um formato canônico, no qual cada entrada é verdadeira ou uma variável complementada e existem apenas dois níveis de portas — um sendo AND e o outro OR — com uma possível inversão na saída final. Essa representação é chamada de representação de dois níveis e existem duas formas, chamadas soma de produtos e produto de somas. Uma representação da soma de produtos é uma
soma lógica (OR) de produtos (termos usando o operador AND); um produto de somas é exatamente o oposto. Em nosso exemplo anterior, tínhamos duas equações para a saída E:
Esta segunda equação está na forma de soma de produtos: ela possui dois níveis de lógica e as únicas inversões estão em variáveis individuais. A primeira equação possui três níveis de lógica.
soma de produtos Uma forma de representação lógica que emprega uma soma lógica (OR) de produtos (termos unidos usando o operador AND).
Detalhamento também podemos escrever E como um produto de somas:
Para derivar esse formato, você precisa usar os teoremas de DeMorgan, discutidos nos exercícios. Neste texto, usamos o formato da soma de produtos. É fácil ver que qualquer função lógica pode ser representada como uma soma de produtos, construindo tal representação a partir da tabela verdade para a função. Cada entrada da tabela verdade para a qual a função é verdadeira, corresponde a um termo do produto. O termo do produto consiste em um produto lógico de todas as entradas ou os complementos das entradas, dependendo se a entrada na tabela verdade possui um 0 ou 1 correspondente a essa variável. A função lógica é a soma lógica dos
termos do produto onde a função é verdadeira. Isso pode ser visto mais facilmente com um exemplo.
Soma de produtos Exemplo Mostre a representação da soma dos produtos para a seguinte tabela verdade para D. Entradas
Saídas
A
B
C
D
0
0
0
0
0
0
1
1
0
1
0
1
0
1
1
0
1
0
0
1
1
0
1
0
1
1
0
0
1
1
1
1
Resposta Existem quatro termos no produto, pois a função é verdadeira (1) para quatro combinações de entrada diferentes. São estes
Assim, podemos escrever a função para D como a soma destes termos:
Observe que somente as entradas da tabela verdade para as quais a função é verdadeira geram os termos na equação. Podemos usar esse relacionamento entre uma tabela verdade e uma representação bidimensional para gerar uma implementação no nível de portas lógicas de qualquer conjunto de funções lógicas. Um conjunto de funções lógicas corresponde a uma tabela verdade com várias colunas de saída, como vimos no exemplo da página B.5. Cada coluna de saída representa uma função lógica diferente, que pode ser construída diretamente a partir da tabela verdade. A representação da soma dos produtos corresponde a uma implementação lógica estruturada comum, chamada array lógico programável (PLA – Programmable Logic Array). Uma PLA possui um conjunto de entradas e complementos de entrada correspondentes (que podem ser implementados com um conjunto de inversores) e dois estágios de lógica. O primeiro estágio é um array de portas AND que formam um conjunto de termos do produto (às vezes chamados mintermos); cada termo do produto pode consistir em qualquer uma das entradas ou seus complementos. O segundo estágio é um array de portas OR, cada uma das quais formando uma soma lógica de qualquer quantidade de termos do produto. A Figura B.3.3 mostra a forma básica de uma PLA.
FIGURA B.3.3 O formato básico de uma PLA consiste em um array de portas AND seguido por um array de portas OR. Cada entrada no array de portas AND é um termo do produto consistindo em qualquer quantidade de entradas ou entradas invertidas. Cada entrada no array de portas OR é um termo da soma consistindo em qualquer quantidade desses termos do produto.
array lógico programável (PLA) Um elemento lógico estruturado composto de um conjunto de entradas e complementos de entrada correspondentes e dois estágios de lógica: o primeiro gerando termos do produto das entradas e complementos da entrada e o segundo gerando termos da soma dos termos do produto. Logo, PLAs implementam funções lógicas como uma soma de produtos.
mintermos Também chamados termos do produto. Um conjunto de entradas lógicas
unidas por conjunção (operações AND); os termos do produto formam o primeiro estágio lógico do array lógico programável (PLA). Uma PLA pode implementar diretamente a tabela verdade de um conjunto de funções lógicas com várias entradas e saídas. Como cada entrada onde a tabela verdade é verdadeira exige um termo do produto, haverá uma linha correspondente na PLA. Cada saída corresponde a uma linha em potencial das portas OR no segundo estágio. O número de portas OR corresponde ao número de entradas da tabela verdade para as quais a saída é verdadeira. O tamanho total de uma PLA, como aquela mostrada na Figura B.3.3, é igual à soma do tamanho do array de portas AND (chamado plano AND) e o tamanho do array de portas OR (chamado plano OR). Examinando a Figura B.3.3, podemos ver que o tamanho do array de portas AND é igual ao número de entradas vezes o número de termos do produto diferentes, e o tamanho do array de portas OR é o número de saídas vezes o número de termos do produto. Uma PLA possui duas características que a ajudam a se tornar um meio eficiente de implementar um conjunto de funções lógicas. Primeiro, somente as entradas da tabela verdade que produzem um valor verdadeiro para pelo menos uma saída possuem quaisquer portas lógicas associadas a elas. Segundo, cada termo do produto diferente terá apenas uma entrada na PLA, mesmo que o termo do produto seja usado em várias saídas. Vamos examinar um exemplo.
PLAs Exemplo Considere o conjunto de funções lógicas definido no exemplo da página B-5. Mostre uma implementação em PLA desse exemplo para D, E e F.
Resposta Aqui está a tabela verdade construída anteriormente: Entradas
Saídas
A
B
C
D
E
F
0
0
0
0
0
0
0
0
1
1
0
0
0
1
0
1
0
0
0
1
1
1
1
0
0
1
1
1
1
0
1
0
0
1
0
0
1
0
1
1
1
0
1
1
0
1
1
0
1
1
1
1
0
1
Como existem sete termos do produto exclusivos com pelo menos um valor verdadeiro na seção de saída, haverá sete colunas no plano AND. O número de linhas no plano AND é três (pois existem três entradas) e também haverá três linhas no plano OR (pois existem três saídas). A Figura B.3.4 mostra a PLA resultante, com os termos do produto correspondendo às entradas da tabela verdade, de cima para baixo.
FIGURA B.3.4 A PLA para implementar a função lógica descrita anteriormente.
Em vez de desenhar todas as portas, como fizemos na Figura B.3.4, os projetistas normalmente mostram apenas a posição das portas AND ou das portas OR. Os pontos são usados na interseção de uma linha de sinal do termo do produto e uma linha de entrada ou uma linha de saída quando uma porta AND ou OR correspondente é exigida. A Figura B.3.5 mostra como a PLA da Figura B.3.4 ficaria quando desenhada dessa maneira. O conteúdo de uma PLA é fixo
quando a PLA é criada, embora também existam formas de estruturas tipo PLA, chamadas PALs, que podem ser programadas eletronicamente quando um projetista está pronto para usá-las.
FIGURA B.3.5 Uma PLA desenhada usando pontos para indicar os componentes dos termos do produto e os termos da soma no array. Em vez de usar inversores nas portas lógicas, normalmente todas as entradas percorrem a largura do plano AND nas formas original e complemento. Um ponto no plano AND indica que a entrada, ou seu inverso, ocorre no termo do produto. Um ponto no plano OR indica que o termo do produto correspondente aparece na saída correspondente.
ROMs Outra forma de lógica estruturada que pode ser usada para implementar um
conjunto de funções lógicas é uma memória somente de leitura (ROM – Read-Only Memory). Uma ROM é chamada de memória porque possui um conjunto de locais que podem ser lidos; porém, o conteúdo desses locais é fixo, normalmente no momento em que a ROM é fabricada. Há também ROMs programáveis (PROMs – Programmable ROMs), que podem ser programadas eletronicamente, quando um projetista conhece seu conteúdo. Há também PROMs apagáveis; esses dispositivos exigem um processo de apagamento lento usando luz ultravioleta, e assim são usados como memórias somente de leitura, exceto durante o processo de projeto e depuração.
memória somente de leitura (ROM) Uma memória cujo conteúdo é definido no momento da criação, após o qual o conteúdo só pode ser lido. A ROM é usada como lógica estruturada para implementar um conjunto de funções lógicas usando os termos das funções lógicas como entradas de endereço e as saídas como bits em cada word da memória.
ROM programável (PROM) Uma forma de memória somente de leitura que pode ser programada quando um projetista conhece seu conteúdo. Uma ROM possui um conjunto de linhas de endereço de entrada e um conjunto de saídas. O número de entradas endereçáveis na ROM determina o número de linhas de endereço: se a ROM contém 2m entradas endereçáveis, chamadas de altura, então existem m linhas de entrada. O número de bits em cada entrada endereçável é igual ao número de bits de saída e, às vezes, é chamado de largura da ROM. O número total de bits na ROM é igual à altura vezes a largura. A altura e a largura, às vezes, são chamadas coletivamente como o formato da ROM. Uma ROM pode codificar uma coleção de funções lógicas diretamente a partir da tabela verdade. Por exemplo, se houver n funções com m entradas, precisamos de uma ROM com m linhas de endereço (e 2m entradas), com cada entrada sendo de n bits de largura. O conteúdo na parte de entrada da tabela verdade representa os endereços do conteúdo na ROM, enquanto o conteúdo da parte de saída da tabela verdade constitui o conteúdo da ROM. Se a tabela verdade for organizada de modo que a sequência de entradas na parte da entrada
constitua uma sequência de números binários (como todas as tabelas verdade mostradas até aqui), então a parte de saída também indica o conteúdo da ROM em ordem. No exemplo anterior, havia três entradas e três saídas. Isso equivale a uma ROM com 23 = 8 entradas, cada uma com 3 bits de largura. O conteúdo dessas entradas em ordem crescente, por endereço, é dado diretamente pela parte de saída da tabela verdade que aparece no exemplo anterior. ROMs e PLAs estão bastante relacionadas. Uma ROM é totalmente decodificada: ela contém uma word de saída completa para cada combinação de entrada possível. Uma PLA é decodificada apenas parcialmente. Isso significa que uma ROM sempre terá mais entradas. Para a tabela verdade anterior, na página B-14, a ROM contém itens para todas as oito entradas possíveis, enquanto a PLA contém apenas os sete termos de produto ativos. À medida que o número de entradas cresce, o número de entradas na ROM cresce exponencialmente. Ao contrário, para a maioria das funções lógicas reais, o número de termos de produto cresce muito mais lentamente. Essa diferença torna as PLAs geralmente mais eficientes para implementar as funções lógicas combinacionais. As ROMs possuem a vantagem de serem capazes de implementar qualquer função lógica com o seu número de entradas e saídas. Essa vantagem facilita mudar o conteúdo da ROM se a função lógica mudar, pois o tamanho da ROM não precisa mudar. Além de ROMs e PLAs, os sistemas de síntese de lógica modernos também traduzirão pequenos blocos de lógica combinacional em uma coleção de portas que podem ser colocadas e ligadas automaticamente. Embora algumas pequenas coleções de portas não façam uso eficiente da área, para pequenas funções lógicas elas possuem menos overhead do que a estrutura rígida de uma ROM ou PLA e, por isso, são preferidas. Para projetar a lógica fora de um circuito integrado personalizado ou semipersonalizado, uma opção comum é um dispositivo programável em campo; descrevemos esses dispositivos na Seção B.12.
Don’t Cares Normalmente, na implementação de alguma lógica combinacional, existem situações em que não nos importamos com o valor de alguma saída, seja porque outra saída é verdadeira ou porque um subconjunto de combinações de entrada determina os valores das saídas. Essas situações são conhecidas como don’t cares. Don’t cares são importantes porque facilitam a otimização da
implementação de uma função lógica. Existem dois tipos de don’t cares: don’t cares de saída e don’t cares de entrada, ambos podendo ser representados em uma tabela verdade. Os don’t cares de saída surgem quando não nos importamos com o valor de uma saída para alguma combinação de entrada. Eles aparecem como X na parte de saída de uma tabela verdade. Quando uma saída é um don’t care para alguma combinação de entrada, o projetista ou o programa de otimização da lógica é livre para tornar a saída verdadeira ou falsa para essa combinação de entrada. Don’t cares de entrada surgem quando uma saída depende apenas de algumas das entradas, e também são mostradas como X, embora na parte de entrada da tabela verdade.
Don’t Cares Exemplo Considere uma função lógica com entradas A, B e C definidas da seguinte maneira: ▪ Se A ou C é verdadeira, então a saída D é verdadeira, qualquer que seja o valor de B. ▪ Se A ou B é verdadeira, então a saída E é verdadeira, qualquer que seja o valor de B. ▪ A saída F é verdadeira se exatamente uma das entradas for verdadeira, embora não nos importemos com o valor de F, sempre que D e E são verdadeiras. Mostre a tabela verdade completa para essa função e a tabela verdade usando don’t cares. Quantos termos do produto são exigidos em uma PLA para cada uma delas?
Resposta Aqui está a tabela verdade completa, sem os don’t cares: Entradas
Saídas
A
B
C
D
E
F
0
0
0
0
0
0
0
0
1
1
0
1
0
1
0
0
1
1
0
1
1
1
1
0
1
0
0
1
1
1
1
0
0
1
1
1
1
0
1
1
1
0
1
1
0
1
1
0
1
1
1
1
1
0
Isso exige sete termos do produto sem otimização. A tabela verdade escrita com don’t cares de saída se parece com esta: Entradas
Saídas
A
B
C
D
E
F
0
0
0
0
0
0
0
0
1
1
0
1
0
1
0
0
1
1
0
1
1
1
1
X
1
0
0
1
1
X
1
0
1
1
1
X
1
1
0
1
1
X
1
1
1
1
1
X
Se também usarmos os don’t cares de entrada, essa tabela verdade pode ser simplificada ainda mais, para mostrar: Entradas
Saídas
A
B
C
D
E
F
0
0
0
0
0
0
0
0
1
1
0
1
0
1
0
0
1
1
X
1
1
1
1
X
1
X
X
1
1
X
Essa tabela verdade simplificada exige uma PLA com quatro mintermos ou pode ser implementada em portas discretas com uma porta AND de duas entradas e três portas OR (duas com três entradas e uma com duas entradas). Isso confronta a tabela verdade original, que tinha sete mintermos e exigiria quatro portas AND. A minimização lógica é crítica para conseguir implementações eficientes. Uma ferramenta útil para a minimização manual da lógica aleatória são os
mapas de Karnaugh. Os mapas de Karnaugh representam a tabela verdade graficamente, de modo que os termos do produto que podem ser combinados são facilmente vistos. Apesar disso, a otimização manual das funções lógicas significativas usando os mapas de Karnaugh não é prática, tanto devido ao tamanho dos mapas quanto pela sua complexidade. Felizmente, o processo de minimização lógica é muito mecânico e pode ser realizado por ferramentas de projeto. No processo de minimização, as ferramentas tiram proveito dos don’t cares, de modo que sua especificação é importante. As referências do livro-texto ao final deste apêndice oferecem uma discussão mais profunda sobre minimização lógica, mapas de Karnaugh e a teoria por trás de tais algoritmos de minimização.
Arrays de elementos lógicos Muitas das operações combinacionais a serem realizadas sobre os dados precisam ser feitas em uma word inteira (32 bits) de dados. Assim, normalmente queremos montar um array de elementos lógicos, que podemos representar apenas mostrando que determinada operação acontecerá a uma coleção inteira de entradas. Dentro de uma máquina, quase sempre queremos selecionar entre um par de barramentos. Um barramento é uma coleção de linhas de dados tratada em conjunto como um único sinal lógico. (O termo barramento também é usado para indicar uma coleção compartilhada de linhas com várias fontes e usos.)
barramento No projeto lógico, uma coleção de linhas de dados que é tratada em conjunto como um único sinal lógico; também é uma coleção compartilhada de linhas com várias fontes e usos. Por exemplo, no conjunto de instruções MIPS, o resultado de uma instrução escrita em um registrador pode vir de uma dentre duas origens. Um multiplexador é usado para escolher qual dos dois barramentos (cada um com 32 bits de largura) será escrito no registrador Result. O multiplexador de 1 bit, que mostramos anteriormente, precisará ser replicado 32 vezes. Indicamos que um sinal é um barramento em vez de uma única linha de 1 bit representando-o com uma linha mais grossa em uma figura. A maioria dos barramentos possui 32 bits de largura; os que não possuem são rotulados explicitamente com sua largura. Quando mostramos uma unidade lógica cujas
entradas e saídas são barramentos, isso significa que a unidade precisa ser replicada por um número de vezes suficiente para acomodar a largura da entrada. A Figura B.3.6 mostra como desenhamos um multiplexador que seleciona entre um par de barramentos de 32 bits e como isso se expande em termos de multiplexadores de 1 bit de largura. Às vezes, precisamos construir um array de elementos lógicos onde as entradas para alguns elementos no array são saídas de elementos anteriores. Por exemplo, é assim que é construída uma ALU de múltiplos bits de largura. Nesses casos, temos de mostrar explicitamente como criar arrays mais largos, pois os elementos individuais do array não são mais independentes, como acontece no caso de um multiplexador de 32 bits de largura.
Verifique você mesmo A paridade é uma função na qual a saída depende do número de 1s na entrada. Para uma função de paridade par, a saída é 1 se a entrada tiver um número par de uns. Suponha que uma ROM seja usada para implementar uma função de paridade par com uma entrada de 4 bits. Qual dentre A, B, C ou D representa o conteúdo da ROM? Endereço
A
B
C
D
0
0
1
0
1
1
0
1
1
0
2
0
1
0
1
3
0
1
1
0
4
0
1
0
1
5
0
1
1
0
6
0
1
0
1
7
0
1
1
0
8
1
0
0
1
9
1
0
1
0
10
1
0
0
1
11
1
0
1
0
12
1
0
0
1
13
1
0
1
0
14
1
0
0
1
15
1
0
1
0
FIGURA B.3.6 Um multiplexador é duplicado 32 vezes para realizar uma seleção entre duas entradas de 32 bits. Observe que ainda existe apenas um sinal de seleção de dados usado para todos os 32 multiplexadores de 1 bit.
B.4. Usando uma linguagem de descrição de hardware Hoje, a maior parte do projeto digital dos processadores e sistemas de hardware relacionados é feita por meio de uma linguagem de descrição de hardware. Essa linguagem tem duas finalidades. Primeiro, ela oferece uma descrição abstrata do hardware para simular e depurar o projeto. Segundo, com o uso da síntese lógica e ferramentas de compilação de hardware, essa descrição pode ser compilada para a implementação do hardware.
linguagem de descrição de hardware Uma linguagem de programação para descrever o hardware utilizado para gerar simulações de um projeto de hardware e também como entrada para ferramentas de síntese que podem gerar hardware real. Nesta seção, introduzimos a linguagem de descrição de hardware Verilog e mostramos como ela pode ser usada para o projeto combinacional. No restante do apêndice, expandimos o uso da Verilog para incluir o projeto da lógica sequencial.. A Verilog do sistema acrescenta estruturas e alguns recursos úteis a ela. Verilog é uma das duas principais linguagens de descrição de hardware; a outra é VHDL. A primeira é um pouco mais utilizada no setor e é baseada em C, ao contrário de VHDL, que é baseada em Ada. O leitor um pouco mais familiarizado com C achará mais fácil acompanhar os fundamentos da Verilog, que utilizamos neste apêndice. Os leitores já acostumados com VHDL deverão achar os conceitos simples, desde que já conheçam um pouco da sintaxe da linguagem C.
Verilog Uma das duas linguagens de descrição de hardware mais comuns.
VHDL Uma das duas linguagens de descrição de hardware mais comuns.
Verilog pode especificar uma definição comportamental e uma estrutural de um sistema digital. Uma especificação comportamental descreve como um sistema digital opera funcionalmente. Uma especificação estrutural descreve a organização detalhada de um sistema digital normalmente utilizando uma descrição hierárquica. Uma especificação estrutural pode ser usada para descrever um sistema de hardware em termos de uma hierarquia de elementos básicos, como portas lógicas e chaves. Assim, poderíamos usar a Verilog para descrever o conteúdo exato das tabelas verdade e o caminho de dados da seção anterior.
especificação comportamental Descreve como um sistema digital opera funcionalmente.
especificação estrutural Descreve como um sistema digital é organizado em termos de uma conexão hierárquica de elementos. Com o surgimento das ferramentas de síntese de hardware, a maioria dos projetistas agora utiliza Verilog ou VHDL para descrever estruturalmente apenas o caminho de dados, contando com a síntese lógica para gerar o controle, a partir da descrição comportamental. Além disso, a maioria dos sistemas de CAD oferece grandes bibliotecas de partes padronizadas, como ALUs, multiplexadores, bancos de registradores, memórias, blocos lógicos programáveis, além de portas básicas.
ferramentas de síntese de hardware Software de projeto auxiliado por computador que pode gerar um projeto no nível de portas lógicas, baseado em descrições comportamentais de um sistema digital. A obtenção de um resultado aceitável usando bibliotecas e síntese de lógica exige que a especificação seja escrita vigiando a síntese eventual e o resultado desejado. Para nossos projetos simples, isso significa principalmente deixar claro o que esperamos que seja implementado na lógica combinacional e o que esperamos exigir da lógica sequencial. Na maior parte dos exemplos que usamos
nesta seção, e no restante deste apêndice, escrevemos em Verilog visando à síntese eventual.
Tipos de dados e operadores em Verilog Existem dois tipos de dados principais em Verilog: 1. Um wire especifica um sinal combinacional. 2. Um reg (registrador) mantém um valor, que pode variar com o tempo. Um reg não precisa corresponder necessariamente a um registrador real em uma implementação, embora isso normalmente aconteça.
wire Em Verilog, especifica um sinal combinacional.
reg Em Verilog,um registrador. Um registrador ou wire, chamado X, que possui 32 bits de largura, é declarado como um array: reg [31:0] X ou wire [31:0] X, que também define o índice de 0 para designar o bit menos significativo do registrador. Como normalmente queremos acessar um subcampo de um registrador ou wire, podemos nos referir ao conjunto contíguo de bits de um registrador ou wire com a notação [bit inicial: bit final], onde os dois índices devem ser valores constantes. Um array de registradores é usado para uma estrutura como um banco de registradores ou memória. Assim, a declaração
especifica uma variável registerfile que é equivalente a um banco de registradores MIPS, onde o registrador 0 é o primeiro. Ao acessar um array, podemos nos referir a um único elemento, como em C, usando a notação registerfile[numreg]. Os valores possíveis para um registrador ou wire em Verilog são ▪ 0 ou 1, representando o falso ou verdadeiro lógico
▪ X, representando desconhecido, o valor inicial dado a todos os registradores e a qualquer wire não conectado a algo ▪ Z, representando o estado de impedância alta para portas tristate, que não discutiremos neste apêndice Valores constantes podem ser especificados como números decimais e também como binário, octal ou hexadecimal. Normalmente, queremos dizer o tamanho de um campo constante em bits. Isso é feito prefixando o valor com um número decimal que especifica seu tamanho em bits. Por exemplo: ▪ 4’b0100 especifica uma constante binária de 4 bits com o valor 4, assim como 4’d4 ▪ – 8 ‘h4 especifica uma constante de 8 bits com o valor - 4 (na representação,
complemento a dois) Os valores também podem ser concatenados colocando-os dentro de { } separados por vírgulas. A notação {x {bit field}} replica bit field x vezes. Por exemplo: ▪ {16{2’b01}} cria um valor de 32 bits com o padrão 0101 ... 01. ▪ {A[31:16],B[15:0]} cria um valor cujos 16 bits mais significativos vêm de A e cujos 16 bits menos significativos vêm de B. Verilog oferece o conjunto completo de operadores unários e binários de C, incluindo os operadores aritméticos (+, –, *, /), os operadores lógicos (&, |, ∼), os operadores de comparação (==, !=, >,