Em termos de potência real da CPU, não há uma estagnação real, pois adicionamos continuamente mais núcleos aos nossos chips pelo que o desempenho continua a aumentar. Mas no que toca à performance por núcleo, há limitações que passam para além da mera capacidade térmica do CPU.
O que impede os atuais CPUs de melhorarem a sua performance?
Como referido antes, as melhorias de performance tem vindo a existir com o acréscimo de mais núcleos aos Chips. Mas a realidade é uma e não se pode escapar a ela: Existe um limite para o paralelismo no nível de instruções dentro do código!
Mass olhando para trás o que vemos: Que o desempenho dos CPUs foi sendo aumentado de duas maneiras:
- Velocidades de relógio mais altas.
- Melhor exploração do ILP.
ILP = capacidade de executar múltiplas instruções simultaneamente
Mas os problemas que a indústria está a enfrentar no que diz respeito ao IPC (Instruções processadas por ciclo de relógio – Um valor real e sempre menor que o ILP teórico) não podem ser resolvidos com o simples acrescentar de mais transistores.
Apesar de tal, essa solução já existiu, e nos anos 90 tivemos projetos que exploravam fortemente o ILP (a capacidade de executar múltiplas instruções ao mesmo tempo), mas agora chegamos a um ponto onde a força bruta já não nos resolve o problema, e daí que surgiu a necessidade de o CPU ser capaz de prever como o código se comporta durante a sua execução.
Deixem-me dar um pequeníssimo código de exemplo:
- ADI r1,r2,#5
- ADICIONAR r3,r1,r2
- ADICIONAR r5,r6,r7
O que este código mostra é a chamada Dependência de Dados. A primeira instrução escreve um valor em r1, que depois a segunda instrução usa como operando. O que isto significa é que estas 2 instruções não podem ser executadas simultaneamente: elas precisam ser executadas uma após a outra pois ao se executar a segunda os valores que são atribuídos na primeira já tem de estar presentes nas variáveis.
Este pequeno código mostra-nos um dos grandes problemas quando queremos executar várias instruções ao mesmo tempo com o intuito de obter melhor desempenho.
Num processador relativamente simples, como um superescalar bidirecional ordenado, as instruções entram no Pipeline aos pares, até atingirem o estágio de execução. Nessa altura um código como o de cima fará com que o pipeline pare até que a dependência existente na segunda instrução seja atendida pela primeira instrução (num caso ideal a espera será apenas de um ciclo, mas pode ser muito mais). O que significa é que com estas duas instruções a entrarem em paralelo, efetivamente usaremos apenas metade da capacidade de execução do hardware pois apenas uma delas pode ser processada.
O que isto nos demonstra é que código fortemente dependente de dados pode não ser executado mais rápido neste CPU superescalar bidirecional ordenado do que num normal CPU escalar (que executa apenas uma instrução de cada vez). Ou seja, na prática, conseguiremos apenas preencher os dois pipelines na maior parte do tempo, desde que com um compilador decente, mas não sempre.
Foi para resolver este problema que surgiu a computação “Out of Order”. Basicamente, para algo como o código acima o CPU pode pegar nas linhas fora da ordem, processando a linha 1 e 3 que não possuem dependências uma da outra. Assim, quando pegasse na instrução 2, ela estaria preparada já para poder ser executada, e se processada com outra instrução sem dependência, dando assim uso total à capacidade de processamento do CPU.
E perante isto, começou-se a desenhar CPU cada vez mais amplos, como o MIPS r10000 de 4 vias, entre outros. Os processadores modernos, como os Skylakes da Intel podem executar até 8 linhas em simultâneo, e os Zen da AMD podem processar 10.
Mas aqui surge-nos outro problema! Quem já programou ou tem umas noções de programação já ouviu falar das instruções If, Else. Estas instruções de Se uma condição for verificada, Então faz isto, irão redirecionar a execução para alguma outra parte do código, dependendo se a condição colocada é atendida.
Num código tipo, estas instruções ocorrem em média a cada 6 instruções, o que cria o chamado Branch, ou ramo, que nos cria aqui um problema!
Basicamente a questão aqui é que não nos é possível saber com 100% de certeza para onde o nosso código vai, É possível prever-se essa direção, e com bastante precisão, mas não se consegue ser 100% preciso, a 100% do tempo!
Ora com esta questão da computação “Out of Order” o CPU olha para as linhas à frente no código de forma a encontrar código que não seja dependente de outro, mas com estas ramificações que se multiplicam, e por vezes que até são múltiplas no mesmo ciclo, como ter a certeza que essa será a instrução certa a ser calculada? Basta que a instrução exista após um ou mais “desvios” e podemos estar a calcular algo que na prática depois não é necessário.
Claro que se for uma instrução necessária, ótimo, mas se não for, temos de libertar todo o Pipeline e todas as instruções especulativas que surgiram deste calculo mas que depois não se aplicam e que precisam de ser descartadas. E isto pode acabar por trazer mais perdas de performance do que realmente ganhos!
Nesse sentido a tecnologia evoluiu para que os CPUs modernos possam prever as ramificações com 98% de certezas. A questão é que com o aumento das ramificações, a precisão diminui, e ao fim de 20 delas, as probabilidades de o resultado estar certo é de apenas 66%
Quer isto dizer, que acaba por haver um limite prático para as previsões que podem ser feitas antecipadamente e enquanto se aguarda um resultado.
E para isto não podemos apenas acrescentar mais transistores. Claro que eles ajudam pois permitem tabelas maiores de histórico de ramificações, e diga-se que esse problema até levou ao desenvolvimento de previsores de ramificação bastante complexos, como o perceptron (“rede neural”) que a AMD usa nos seus CPUs Zen.
Mas a realidade é que esta questão das previsões é um problema, e basicamente, apesar de poder ser melhorado, não é um problema que possa ser resolvido. Não é possível ter-se 100% de certeza quando se trata de previsões, e naturalmente que quantas mais se acumularem umas sobre as outras, menor é a precisão garantida do resultado.
Não fora por esses problemas com ramificações, e podíamos ter processadores que executam 20, 50 ou 100 instruções de uma só vez, até porque todos os anos se acrescentam mais transistores nos processadores. A excepção nesse nível de processamento são os processadores de Vetor/matriz como os usados nos GPUs, mas que não são adequados para código de uso geral como o realizado pelo CPU.
Infelizmente, sempre vai haver a necessidade de processamento em cadeia, independente de quão boa for a programação ou linguagem, já que algumas informações não existirão antes de serem calculadas, a solução pra isso ou é aumentar a velocidade mesmo ou mudarem por completo o modo como os computadores funcionam, algo como um processador quântico, por exemplo, pois não vejo outra solução pra essa questão no meu raciocínio limitado. Hoje, o que parece mais promissor como evolução para no single core, pelo que se conhece, é trocar o silício por grafeno e empurrar os processadores a novos limites de clock.