Sabia que você é um deficiente visual?

Well… prevejo leitores questionando o que eu disse no artigo anterior: “Mas, Fred, eu vejo claras diferenças entre 720p e 1080p no meu monitor de 20 polegadas!” ou simplesmente “Você não sabe de nada, vai estudar!”… Bem, saiba que, assim como todos nós, você é um deficiente visual:

Existem diferenças essenciais entre o que é mostrado num monitor de vídeo e o que o seu olho (e seu cérebro) percebe. Para começar, o olho humano não consegue, em circunstâncias normais, perceber o espectro eletromagnético abaixo da frequência do vermelho (por isso o prefixo “infra” em infra-vermelho), e acima do violeta (ultra-violeta). Sem contar com as faixas de raios-X e ondas de radio. Mas, o pressuposto de que nossos olhos são “perfeitos” para perceber todo o espetro luminoso (de 430 THz até 770 THz) é falso. Não só isso, mas a percepção espacial e de movimento também são falhas.

No “mundo real”, onde não existe uma matriz de pixels, nossos olhos são facilmente enganáveis. Os fenômenos chamados de “ilusão de ótica” comprovam isso. Existem, pelo menos, 4 tipos de ilusões visuais:

  1. Espacial;
  2. De cores;
  3. De movimentos;
  4. De percepção.

Ilusões espaciais

O espaço é o “lugar” onde as coisas estão e podem ser percebidas. Para nos locomovermos contamos com a precisão da interpretação dos objetos contidos no espaço. Nem sempre conseguimos. Eis 3 exemplos:

Na imagem à esquerda temos uma espiral contínua, mas a percebemos de forma descontínua. No centro, as linhas horizontais são perfeitamente paralelas, mas parecem “quebradas” e, na última figura, a Torre de Pizza da direita parece estar mais inclinada do que a da esquerda, embora ambas sejam a mesma foto. Os exemplos não exploram a descontinuidade entre pixels, ou seja, a “ilusão” é macroscópica!

Além dessas distorções espaciais, temos também o efeito de “falsa perspectiva”, o que significa que, em certas condições nossos olhos e cérebros não são capazes de medir a distância relativa entre objetos corretamente. Tem um monte de gente que visita a Itália que tenta replicar o efeito, justamente, com a Torre de Pizza:

Seguuuura!!!!

Isso significa que é arrogância alguém pensar que consegue perceber detalhes a nível de pixel se sequer consegue percebê-los ao nível macro. Especialmente em imagens em movimento.

Ilusões de cores

Outra arrogância é assumir a supersensibilidade ocular em relação às cores. As imagens abaixo demonstram:

No caso da figura da esquerda, os quadrados A e B tem a mesma cor. A mesma coisa acontece com a figura à direita, onde o centro dos “quadrados” também tem a mesma cor. Não acredita? Clique com o botão direito nas imagens (são duas, lado a lado), salve os arquivos e use o seu editor gráfico preferido (GIMP, Photoshop). Obtenha a cor das regiões citadas com a ferramenta Color Picker e veja por si mesmo!

De novo, o efeito não usa a descontinuidade ao nível de pixels, mas de forma macroscópica. Isso significa que nossa percepção de cores é um tanto quanto falha (muito, aliás!). A coisa só piora (para nós) quando lidamos com a variação, no tempo, das cores de um vídeo. Por exemplo, se variarmos alguns pixels de vermelho para laranja e de volta para vermelho, jamais perceberemos… De fato, a maioria dos codecs conta com essa deficiência para realizar a mágica da compactação.

Ilusão de movimento

Algumas imagens perfeitamente estáticas podem parecer estar em movimento e vice-versa. Em outros casos, o movimento observado não é, necessariamente, o que está acontecendo:

Na imagem da esquerda as 3 “barras verticais giratórias” parecem querer se movimentar para a direita, não? Já na imagem da direita, alguns quadrados parecem estar “querendo” girar, dependendo de onde você olha para a imagem, mas garanto que todos os quadrados estão “paradinhos”.

Às vezes percebemos movimentos que não estão lá…

Iluão de percepção

Essa é muito interessante e também acontece num nível ainda mais macro que os anteriores. Não percebemos detalhes, mas somente o “quadro geral” de acordo com o contexto. Isso pode ser demonstrado nos vídeos abaixo. Assista-os e depois me diz o que acha.


Provavelmente, se você não conhecia os experimentos, não notou o gorila na primeira vez que viu o primeiro vídeo e só reparou nas modificações da cena, ao estilo “Hercule Poirot”, depois que mostraram, não foi? Esses são exemplos extremos, assim como os que mostrei antes, mas demonstram que não percebemos mudanças sutis, nem no que se refere a um acontecimento, nem com o que esperamos que esteja acontecendo…

Além das “ilusões”…

Ficou claro que nossos olhos e cérebros estão longe da perfeição? Considere também que o globo ocupar não é perfeitamente esférico, não tem distribuição uniforme das células sensoras de luz (cones e bastonetes), não têm alinhamento perfeito (você provavelmente tem dois olhos, não?), o líquido que preenche o globo ocular (humor vítreo) não tem densidade uniforme e sequer os cones e bastonetes têm acesso direto á luz (eles são “virados para dentro”, por assim dizer!).

Outros detalhes: Existem pontos-cegos no seu campo visual, localizados, mais ou menos, no centro dos campos individuais de cada olho. Não acredita? Você pode fazer o experimento com o seu mouse pointer. Clique na imagem abaixo, maximize-a, coloque a setinha do mouse pointer sobre a bolinha preta… Com a mão esquerda tape seu olho esquerdo e fixe seu olhar (com o olho direito, dã!) na bolinha… Não deixe de ficar focado na bolinha preta… Agora, vá afastando o mouse pointer, devagar, para a direita. Você verá que, pouco depois da bolinha, a setinha “some” e, a medida que você vai afastando-a para a direita, ele reaparece:

Se temos esse “ponto cego”, como é que percebemos as imagens de forma contínua?

Nossos olhos não ficam parados, o movimento para obter foco é constante, mesmo que seja mímimo. Além disso, temos dois olhos e o cérebro usa a imagem obtida em ambos

Além do ponto cego, nossa visão periférica é quase que totalmente em escala de cinza… Somado a isso tudo, seu cérebro realiza interpolações o tempo todo à respeito da imagem e das cores, bem como do movimento. Já a “percepção” da cena, como vimos, sofrem de deficiências “psicológicas”.

O mito dos quadros por segundo

Além da resolução gráfica e das cores, sua percepção relativa à quantidade de “quadros por segundo” também não é tão boa, em comparação com alguns insetos e aves. Para humanos, a ilusão de movimento em cenas animadas por ser obtida à partir da exibição de, mais ou menos, uns 15 quadros estáticos por segundo (uns 60 ms por quadro). Na prática, qualquer coisa maior que 24 fps (frames per second) não permite a percepção individual dos quadros. Este é o motivo pelo qual alguns vídeos continuem seguindo padrões como NTSC (23.97 fps) e PAL (29.97 fps) e, raramente, passem de 30 fps.

Pombos, por exemplo, têm a ilusão de movimento à partir de uns 120 fps. Televisão e cinema deve ser um troço bem desinteressante para eles…Alguns insetos precisam de taxas ainda maiores para ter a ilusão…

Mas, porque jogos, hoje em dia, usam taxas de 60 fps? Embora a ilusão de movimento em 24 fps seja “perfeita”, isso não significa que nossos olhos não possam interpolar os dados entre dois frames. É algo mais ou menos como supor que tenhamos dois valores inteiros: 1 e 1, e colocamos um valor 0 no meio. O efeito geral será um pequeno decréscimo na média percebida, ao invés de percebermos 1, perceberíamos, talvez, um 0,7.

Jogos usam esse artifício para suavizar certos efeitos de movimento como “motion blur”, por exemplo… Mas, note, isso não significa que consigamos lidar com mais que 30 fps, de fato, em taxas como 60 fps, a maioria dos quadros são duplicados e, em alguns poucos casos isolados, onde há muito movimento dos objetos em cena, é que pode-se tirar vantagem da persistência da retina para “suavizar” ou “ressaltar” o efeito.

Outro motivo para taxas altas é o uso de 3D. Aquele óculos que você usa no cinema tem lentes eletricamente polarizadas. Ora a lente direita está escura, ora a lente esquerda. Isso tem que ser feito muito rapidamente para que o efeito de persistência da retina não seja afetado. Costuma-se transmitir um vídeo “3D” em 120 fps, onde 60 quadros vão pro olho direito e 60 para o esquerdo, intercaladamente… Aliás, bons óculos 3D não polarizam as lentes, mas persistem os quadros. Infelizmente esse tipo de óculos é tremendamente caro. O efeito de polarização, mesmo que por um pequeníssimo intervalo, causa um estresse visual, podendo causar desde dores de cabeça até irritação ocular (seus olhos ficam buscando foco o tempo todo, mesmo que por intervalos ínfimos – considere que o filme em questão também atrai o foco para diferentes locais da tela!).

Se seu vídeo não tem capacidade 3D é perfeitamente seguro diminuir o framerate para 23.97 (NTSC) ou 29.97 (PAL). Isso pode ser feito com o ffmpeg via opção -r. Dito isso, é bom notar que o formato de 59.94 fps está se tornando default em grandes resoluções nos últimos anos e existem variantes do PAL-M para 60 fps (não sei se existe para NTSC). Observação: Os padrões NTSC e PAL-M podem ser seguramente desconsiderados em tempos de “TV digital”, mesmo porque eles não oferecem as resoluções necessárias para transmissão de sinal com resoluções de 720p ou superiores. O modelo do PAL para 60 fps, suspeito, tem esse suporte, mas eu não apostaria nisso!

Chamei de “mito” o uso de 60 fps porque não é porque temos mais quadros por segundo é que teremos uma experiência “melhor”. Isso depende dos vídeos que, geralmente, são feitos para serem mostrados em framerates mais baixos. Não é o caso da maioria dos jogos modernos, no entanto…

Um último aviso é relativo à velocidade de seu monitor… Para mostrarmos animações com 60 fps o monitor deve ter velocidade inferior a 16.6 ms por quadro (\frac{1}{60}=0,0166...). Muitas TVs não têm essa velocidade toda (na faixa ligeiramente superior a 20 ms por quadro), o que implica que 30 fps é, mais ou menos, o limite superior… Isso é diferente de monitores para computadores, que são mais caros porque têm, justamente, uma resposta mais rápida… Não adianta nada ter um vídeo com 60 fps e seu monitor só suportar 30. Especialmente se não for um “monitor”, mas uma “SmartTV”…

Videos: O que você provavelmente não sabe…

Em 2015 escrevi um artigo, por aqui, falando sobre áudio, que me custou comentários de alguns ‘haters’ (foi este aqui). Vamos ver se consigo que alguns amantes de ‘vídeos’ consigam me divertir, enchendo o meu velho e enrugado saquinho com este aqui…

Recentemente, um conhecido tentou me convencer de algumas coisas completamente falsas, tanto do ponto de vista fisiológico quanto do ponto de vista técnico, a respeito de vídeos. Quando falo de vídeos aqui, quero dizer imagens em movimento, como AVI, MPEG-4 etc.

A primeira bobagem é a de que os componentes de cada uma das três cores primárias do espectro luminoso (R, G e B – de vermelho, verde e azul) podem ser divididas em degraus menores que \frac{1}{256}, ou seja, que cada um desses componentes pode ter mais que 8 bits de tamanho. Fisiologicamente falando, nossos olhos não são capazes de perceber mais que 16,7 milhões de cores em superfícies que chamo de “emissivas” (uma fonte que emite luz)… Somos sim, capazes de perceber mais que isso (umas 50 milhões) em superfícies “reflexivas” por causa de imperfeições. É bom notar que os photons “refletidos” não são somente aqueles que “batem” na superfície e são desviados, mas também são absorvidos e emergem novamente.

Com o limite de 16,7 milhões de cores, o uso de 8 bits para cada um dos componentes é mais que suficiente, o que nos dá uma resolução para os três componentes de 24 bits. Mas, se esse é o caso, porque algumas placas de vídeo supostamente suportam mais que 8 bits por componente? A resposta é elas não suportam! Mesmo que o formato do pixel no framebuffer seja de 30 bits (10 para cada componente), se isso existe, é porque a placa é capaz de fazer um downsampling em hardware, um arredondamento. No fim das contas os pixels são compostos de 24 bits.

Este é o mesmo princípio do formato de pixels que contenham um canal Alpha. Os 8 bits do componente alpha não compõem a cor final do pixel, mas ele pode estar presente no formato do pixel, no framebuffer, e é usado no processamento de blending (mistura) de cores… De novo, no fim das contas, o pixel terá apenas 24 bits.

Vídeos não costumam ser codificados em RGB

Um pouco de história: No início da transmissão de sinal de vídeo para TVs, lá pelos anos de 1950, tinhamos apenas o formato preto-e-branco. O sinal era em “escala de cinza”, erroneamente chamado de “luminância” (embora a nomenclatura seja errada, usarei aqui porque é a usada em literatura técnica)… Embora a disseminação da televisão tenha começado nos anos 1950, a tecnologia existia, pelo menos, desde os anos 30. O mesmo aconteceu com a TV colorida: A disseminação das mesmas começou nos anos 70, mas o modelo também existia desde a época da invenção da TV preto-e-branco, mas não existiam padrões para transmissão de cores.

O padrão NTSC surgiu no início dos anos 40 e aproveita o esquema da transmissão de sinal preto-e-branco perfeitamente. A amplitude do sinal em cada linha nos dá a luminância (chamado de Y’) e a defasagem de uma senoide incorporada no sinal, modulando-o, e com um sinal de referência de fase no pulso de sincronismo horizontal, nos dá duas componentes de crominância (cor), chamados de I e Q (obtenção desses valores com base no ângulo de defasagem está além do escopo desse artigo!). A ideia é aproveitar o valor médio do sinal para cada pixel para continuarmos vendo o sinal em preto-e-branco nas TVs desse tipo e a diferença de fase entre a senoide modulada no intervalo do pixel e o sinal de referência. Ambos os sinais de crominância tem frequência de 3,579545 MHz (para o NTSC, no PAL-M, sistema usado no Brasil, a frequência é ligeiramente diferente)… No caso de TVs preto-e-branco, o circuito simplesmente ignora a modulação e o sinal de referência.

No caso de vídeos digitais, Y’, I e Q são substituídos por Y, U e V, mas a ideia é a mesma: Y é a luminância ou intensidade e U e V são os sinais de cores. Esses dois sinais de crominância são usados para calcular o trio RGB tomando G como referência. Por que G? Temos mais sensibilidade à cor verde do que qualquer uma das outras duas, assim, as outras duas precisam ser “reforçadas”, por assim dizer, permitindo que G seja “calculada” ao invés de diretamente fornecida. De fato, a conversão de RGB para YUV, no formato BT.709 (costumeiramente usado em formatos de vídeo digitais), é essa:

\displaystyle \begin{bmatrix}  Y\\  U\\  V  \end{bmatrix}=\begin{bmatrix}  0.2126 & 0.7152 & 0.0722 \\  -0.09901 & -0.33609 & 0.436 \\  0.615 & -0.55861 & -0.05639  \end{bmatrix}\begin{bmatrix}  R\\  G\\  B  \end{bmatrix}

Repare que ao obtermos Y o componente G tem maior peso do que os outros dois componentes. Da mesma forma, U e V podem ser entendidos, mais ou menos, como a diferença entre B e R com relação a G, respectivamente (embora exista um pequeno componente de G neles também).

Isso tudo é muito interessante e coisa e tal, mas porque diabos usar YUV ao invés de RGB em formatos digitais? Embora as cores sejam importantes, por incrível que pareça, não somos tão sensíveis a elas assim. Nossos olhos têm, basicamente, dois conjuntos de células detectoras de photons: Os cones e bastonetes.  Os bastonetes são as células que detectam a “luminância”, mas não detectam cores. Elas são cerca de 100 vezes mais sensíveis que os cones (que detectam cores) e distribuídas, mais ou menos, de maneira uniforme na retina (embora tenham densidade maior na visão periférica), enquanto os cones concentram-se numa pequena região, chamada fovea. Nossa visão periférica não percebe muito bem as cores, embora nossos cérebros “montem” a imagem colorida para nós… Isso nos leva, de novo, ao modelo YUV, usado em transmissão de sinais de vídeo…

Como as cores não são tão importantes assim, do ponto de vista fisiológico, os componentes U e V podem ser “diminuídos” e até mesmo “duplicados” sem que exista grande perda de qualidade visual. Por exemplo, para cada 4 pixels podemos ter 4 componentes Y (um para cada pixel), mas apenas 2 de cada componente U e V. Considerando esse array de 4 pixels hipotéticos temos, em RGB, um gasto de 96 bits (24 para cada pixel), mas com o esquema do YUV 4:2:0 temos o gasto de apenas 48 bits (32 bits de Y, 8 para cada pixel; e 16 bits para U e V, que será “replicado” nos 4 pixels!):

Arranjos  Y, U e V e os hipotéticos para YUV 4:2:0

Sim! A maioria dos vídeos que você assiste está codificado no formato YUV 4:2:0 onde, 4 pixels têm a mesma cor, mas podem ter intensidades diferentes! O formato YUV 4:4:4 raramente é usado por não ter vantagem sobre o RGB e, ainda, o YUV 4:2:2 não apresenta grandes vantagens, do ponto de vista fisiológico (embora seja usado em BluRays, por exemplo).

Experimente obter a informação do vídeo daquele filme em altíssima qualidade que você baixou na Internet e veja:

$  ffprobe Pan\'s.Labyrinth.2006.720p.BluRay.x264-\[YTS.AM\].mp4 2>&1 | \
   grep 'Stream.\+Video'
Stream #0:0(und): Video: h264 (High) (avc1 / 0x31637661), yuv420p, 1280x682...

Ok. a resolução 720p não é lá de “altíssima qualidade”, mas é porque não tenho nada melhor aqui para demonstrar… Experimente a mesma linha de comando de vídeos em 1080p e 2160p que você baixar por ai e verá que, provavelmente, todos usam o formato YUV 4:2:0 (p de “progressivo” – que indica a ordem com que os componentes aparecem no formato).

A maioria das placas de vídeo que suportam decodificação MPEG por hardware suportam esses padrões nativamente (incluindo a Graphical Processing Engine da Intel!). Em grosso modo, o framebuffer poderá ser dividido numa área que conterá os valores de Y e duas áreas, com metade do tamanho cada (YUV 4:2:2) ou \frac{1}{4} do tamanho cada (YUV 4:2:0), para os valores de U e V.

Sobre a resolução gráfica

Nossos olhos não são muito bons para perceber altas densidades de pixels. Aliás, qualquer coisa maior que uns 72 DPIs num monitor torna a percepção de descontinuidade praticamente inexistente! De novo, isso é diferente para superfícies reflexivas, onde algo abaixo de 150 DPIs pode ser percebido. Mas, estou falando de monitores aqui.

Com o limite inferior de 72 DPIs, se desenharmos uma linha de 2,54 cm de tamanho com 72 pontos, lado a lado, não veremos os pontos isoladamente, mesmo que exista um espacinho entre eles… Aliás esse é outro ponto de interesse para quem lida com vídeo: A densidade de pixels na tela para uma determinada resolução pode ser calculada e, frequentemente, os drivers de vídeo fazem isso para nós… Assim, se temos 72 PPI (pixels por polegada), ao desenhar 72 pixels temos a garantia que teremos uma linha de 1 polegada na tela… Mas, como veremos, essa medida não costuma ser exata…

A existência de resoluções maiores deve-se a essa densidade relativa (relativa ao tamanho da área de projeção) dos pixels. Quanto maior o monitor, mais espaçados estarão os pixels ou serão maiores. Daí a resolução gráfica é importante: quanto mais pixels, menos espaço entre os pixels ou, obviamente, menor serão… Ou seja, a resolução esconde a descontinuidade, com o efeito colateral de aumentar a densidade. Calcular a densidade relativa média é simples. Só temos que ter a resolução horizontal, a vertical e o tamanho da diagonal, em polegadas:

\displaystyle Densidade_{(ppi)}=\frac{\sqrt{\left(Pixels_{horizonal}\right)^2+\left(Pixels_{vertical}\right)^2}}{Tamanho_{diagonal}}

Com uma resolução de 1920×1080 e um monitor de 24″ (tenho um desses!) temos \frac{\sqrt{1920^2+1080^2}}{24}, ou seja, cerca de 92 PPIs. Mas, se usássemos um monitorzão de 60″ teríamos cerca de 36 PPIs… Como falei, qualquer coisa abaixo de 72 DPIs é inaceitável para um monitor. Assim, usar resolução Full HD num monitor de 60″ nos causará estranheza (pixels grandes ou muito espaçados – veja a figura abaixo)! Se usarmos uma resolução Ultra HD (4K ou 3840×2160) teremos uns 73 PPIs. Note que no monitorzão de 60″ a densidade dos pixels está no limite da nossa percepção de descontinuidade, para essa resolução gráfica (se considerarmos 72 DPIs como limite).

Densidade de pixels

Para obter informações mais precisas podemos usar o utilitário xdpyinfo:

$ xdpyinfo -ext all
...
screen #0:
  dimensions:    1920x1080 pixels (508x285 millimeters)
  resolution:    96x96 dots per inch
  depths (7):    24, 1, 4, 8, 15, 16, 32
...

Aqui temos o tamanho real do monitor (em milímetros), vindo do próprio monitor via EDID.

Resoluções recomendáveis

Assistir um filme em HD (720p) é interessante se seu monitor tiver até 20″, Full HD (1080p) até 30″ e Ultra HD (2160p) acima de 30″, se tivermos 72 PPI como parâmetro mínimo de densidade relativa. Mas, temos que considerar outra coisa: A projeção do pixel na nossa retina, relativa à distância do observador à superfície de projeção! Quanto mais distantes seus olhos estão do monitor, menor será o pixel percebido. Conseguíamos ver isso nas antigas TVs de CRT: Quando encostávamos a cara na tela, conseguíamos ver as pequenas “células” RGB que compunham a grade (de novo, veja a imagem anterior). Não dá para fazer isso, hoje (porque a densidade é alta – 72 DPI ou mais, lembra?)…

Embora a resolução “gráfica” relativa do olho seja bem alta (mais que 500 Mpels), ela não é linear e prejudicada nas bordas do campo de visão, tanto pela percepção das cores, quanto pela resolução… Um humano “padrão” tem cerca de 120° de campo de visão total, mas a fovea tem de 5° a 15º apenas. Isso te permite dar atenção a uma faixa bastante estreita do campo de visão e, por isso, é recomendável que a distância de visualização da superfície do monitor seja cerca, pelo menos, \sqrt{2} vezes o diâmetro do mesmo, no mínimo. Na prática, para maior conforto, o valor mínimo deve ser 2 ou mais.

Graças a essa distância mínima, podemos supor que, para obtermos pelo menos 72 PPI na retina, os tamanhos relativos das superfícies dos monitores podem ser multiplicadas por \sqrt{2}. Assim, 720p é viável para monitores de até 28″, 1080p para até 42″ e 2160p acima disso.

Isso significa que, por mais chato que você seja, se tiver um monitor de 24″ como eu, assistir filmes 1080p não implica em melhor qualidade visual (dos pixels – pode-se pensar “qualidade” em outros termos!), mesmo que sua resolução seja 1080p! O olho humano não é capaz de perceber a diferença! Eu, por exemplo, uso 1080p em meu monitor de 24″ para fazer caber mais conteúdo na tela, não para compensar descontinuidades!

Jargão técnico: Metáforas, alusões e siglas

A motivação pela escrita desse artigo surgiu de uma pergunta que me foi feita a respeito de “referências”, um conceito ou alusão a um “tipo” (outra alusão) de variável… Me perguntaram se a linguagem C tem, ou não, “referências”. Espero responder a isso no final.

Jargão

Uma das principais coisas a serem dominadas por estudantes e profissionais de quaisquer áreas é o jargão técnico específico. Qualquer profissional na área de engenharia elétrica que diga “neste fio circulam 127 V de corrente” vai ser zoado, e com razão! O mesmo aplica-se ao profissional de desenvolvimento de software que diga que use “megagem” ou “gigagem” de memória, querendo expressar ordem de grandeza de certa quantidade de recursos (obs: Um amigo mineiro me disse, uma vez, que era comum ouvir isso nos lugares onde trabalhou!).

Além de tomar cuidado para não passar vergonha em público, é imprescindível que usemos as analogias e alusões, próprias do jargão, de forma correta para que possamos ser compreendidos e evitemos confusões. Isso é especialmente necessário se você, como eu, pretende escrever material de consumo público que objetive “ensinar” alguma coisa a alguém (jeito bonitinho de falar “livros”!)… Neste artigo tendo mostrar a origem e usos de alguns termos.

Alusões literárias

Quando lidamos com unidades de armazenamento de dados e agrupamentos dessas unidades é comum o uso de analogias relacionadas com “livros”. Os primeiro termo, que especifica a menor quantidade de informação possível, não é uma alusão a algum objeto físico, mas um acrônimo: bit vem de “digito binário” (binary digit). A referência aqui é aos “digitos decimais” e, especificamente, “digito” porque temos a propensão de contar nos “dedos”… O termo correto seria “algarismo binário” (bismo?), mas “bit” expressa duas coisas: Um termo bem simples de ser lembrado e uma quantidade, na língua inglesa.

À partir de byte começa-se a usar alusões menos explícitas. A palavra faz alusão a um conjunto de bits, a um “bocado” de bits… ou a uma “mordida” (bite). Dos termos que lidam com quantidade de bits esse é o menos “literário” deles… Hoje, um byte equivale a um conjunto de 8 bits, mas o termo não especifica essa quantidade! Originalmente, um byte pode conter quantidades arbitrárias de bits. Em computadores mais antigos, um byte continha 9 bits, por exemplo (se não me engano, o antigo PDP-7 era assim!).

Definido o byte alguém desejou que alguma analogia similar fosse usada para pedaços menores, mas maiores que um único bit. Se byte é definido (hoje) como 8 bits, define-se a metade dessa “mordida” como uma “beliscada” (nibble)… Um nibble tem 4 bits de tamanho, metade de um byte.

Já o termo palavra (word) surge para explicitar um conjunto de bits que é usado nativamente pelo processador em questão. Nos antigos processadores de 8 bits (Z-80, 6502, 8080…) uma word tinha um byte de tamanho. Nos antigos 8086, uma word tinha 2 bytes (16 bits) de tamanho e essa designação “pegou”, tornando-se corriqueira, hoje em dia.

Para especificar tamanhos maiores que uma word outras alusões apareceram: 32 bits tornou-se double word ou dword, 64 bits é uma quadruple word ou quad word (qword) e, menos usara, 128 bits é uma octuple word ou octa word… Outra designação menos usada, hoje em dia, para 128 bits é um parágrafo. Essa alusão é ainda usada em assembly, particularmente no Microsoft Macro Assembler para especificar o alinhamento de um segmento (outra alusão literária):

_TEXT segment para public 'CODE'
...
_TEXT ends

Esse para ai da especificação do alinhamento de um segmento significa parágrafo (ou paragraph) que diz ao compilador que o início desse segmento deve começar num offset múltiplo de 16 (16 bytes = 128 bits).

Não tenho conhecimento sobre outras designações literárias para tamanhos maiores de 16 bytes a não ser a página. Por causa do uso de “memória virtual”, um bloco de 4 KiB (nos processadores Intel) têm essa designação.

Note que um parágrafo é maior que uma palavra e uma página é maior que um parágrafo, isso também é intencional na analogia.

Um exemplo de alusão literária de nível mais alto

Já falei dessa alusão antes: Little Endian e Big Endian é uma alusão aos versos de “Humpty Dumpty“, que é tão famoso em países de lingua inglêsa quanto alguns personagens do folclore brasileiro são, por aqui (Saci Pererê, Mula Sem-cabeça, Curupira)… Nos versos há uma discussão onde tenta-se decidir se um ovo deve ser quebrado pelo seu lado mais pontudo ou “menor” (little endian – o “final” menor) ou pelo lado mais “rombudo” ou “maior” (big endian).

Esses termos entraram no jargão porque houve mesmo uma discussão acirrada em relação ao posicionamento dos bytes de uma word, nos anos 50: Deve colocar o byte menos significativo no menor endereço de memória ou vice-versa? Note que a turma que lida com “redes” optou por escrever uma word, ou tipos que tenham tamanho maior que 1 byte, na ordem em que a lemos, ou seja, big endian: Se quisermos escrever a word 0x1234 o byte 0x12 é escrito antes (no endereço menor) do que 0x34. Já os processadores atuais (os mais usados, pelo menos) preferem escrever o byte de mais baixa ordem no menor endereço (0x1234 aparece na memória na sequência 0x34, 0x12) ou little endian.

Ai está: Quando falamos de endianess estamos falando de um ovo!

Linguagens de programação e suas alusões

Existem diversos termos usados em linguagens de programação que são alusões a objetos ou conceitos que nada têm a ver com “programação”. Muitos deles são relacionados à matemática porque, ora bolas, afinal de contas, um algoritmo é uma construção matemática! Programas são conjuntos de expressões matemáticas e um conjunto de expressões coerente que serve como instruções ou informações é, no mundo “real” conhecido como linguagem. Uma “linguagem” também é uma referência literária, se você pensar bem…

Acontece que existe uma Torre de Babel nessa área. Termos como “variáveis” e “objetos”, “estruturas” e “classes” são intercambiáveis e todos são alusões… Não existem “variáveis”, mas “containers” que podem assumir vários valores.. Não existem “objetos”, mas containers de valores (que são chamados também de “variáveis” – confuso, não?)… Não existem “classes”, nem no sentido de “agrupamento social” (sociologia?) ou “categoria taxionômica” (biologia), mas estruturas que agrupam “variáveis” (ou seriam “objetos”?).

Embora muitas das analogias sejam similares, elas são usadas para diferenciar conceitos ligeiramente diferentes. Ao falarmos de “classe”, hoje, queremos dizer “estrutura”, mas com um plus: Junto com os dados, as “funções” que manipulam esses dados podem estar encapsulados junto com eles e damos a isso o nome de “classe”. Note que, mesmo nessa explicação usei duas alusões (função e encapsular) que, para o purista, estudante de “orientação a objetos”, é uma heresia. Para esses os termos corretos seriam “comportamento” e “agregar” (talvez “compor”, mas acho que “encapsular” pode ser perdoado!)… Assim como dizer que classes têm “dados” também me condenaria à fogueira. O termo certo seria “estados”.

É interessante notar que, assim como no mundo real, alguns conceitos surgem em oposição a outros. Por exemplo, o termo “variável” surge em oposição ao termo “constante”… Matematicamente, “variável” não é tradicionalmente usado, mas “contante” o é… Então, provavelmente quando as primeiras linguagens de programação estavam sendo estudadas (ALGOL, por exemplo), o termo “variável” apareceu no jargão.

Linguagem C: Referências e Ponteiros

Ponteiro é uma alusão a um método indireto de acesso a uma variável. Ao invés de lermos ou escrevermos (outras alusão à literatura!) o dado diretamente, “apontamos” para ele e usamos esse “ponteiro” para manipular o dado, indiretamente.

Ora… Uma “referência” é uma “método indireto de acesso a uma variável” (ou objeto… puts!). Uma referência é um ponteiro! Assim, C possui referências! Simples assim.

O estudante pode ficar confuso e tentar argumentar: “mas não é desse ‘tipo’ de referência que estou falando!”. Acontece que esse “tipo” de referência é apenas uma construção sintática. Por exemplo. Isaac Newton, em seu Principia Matematica define suas leis de forma puramente textual. Se você ler o livro verá que não existe uma só equação matemática por lá, no máximo, figuras geométricas. Por quê? Tradição! No século XVII a aritmética já era conhecida, mas o “atalho” da “prova matemática” era considerado algo que só alguém “não educado” faria.

No entanto, se Newton dissesse algo como “Ao sofrer variação de velocidade, um corpo qualquer com massa conhecida sofre a influência de uma força proporcional” ou escrevesse simplesmente “F=m\cdot a“, estaria dizendo, essencialmente a mesma coisa, não? E essa é a diferença entre dizer que um ponteiro é uma referência e uma referência é um ponteiro, ou seja, essencialmente nenhuma!

Mais problemas com inlining

No último texto mostro que usar funções inline nem sempre é uma boa ideia e também nem sempre o compilador respeitará a “dica” do “inline”. Mas, existe uma condição em que o compilador tende a transformar suas rotinas em “inline”: Quando elas têm tamanho que atendem o critério do próprio compilador e são definidas no próprio módulo (arquivo com extensão .c), ele a transforma em “inline” mesmo sem a dica. Um exemplo:

unsigned int sum(char *bufptr, size_t size)
{
  unsigned int s = 0;

  while ( size-- )
    s += *bufptr++;

  return s;
}

unsigned int f(unsigned int scale, 
               char *bufptr, size_t size)
{
  return scale * sum( bufptr, size );
}

Aqui a função sum() provavelmente será compilada inline deontro da função f() mas, como temos duas funções “extern”, por default, tanto a versão inline da chamada quanto a função original são passadas para o linker.

Terminamos com duas versões da mesma função… E o linker não tem a menor ideia disso. Não é tarefa do linker saber se uma função está ou não sendo usada já que ela pode ser chamada de forma indireta, por exemplo, através de um ponteiro:

unsigned int (*fp)(char *, size_t);
...
fp = sum;
x = fp(buffer, sizeof buffer);

Esse fragmento chamará sum(), mais ou menos assim:

  ...
  mov  rax,sum
  mov  rdi,buffer
  mov  rsi,N      ; N é o tamanho do buffer.
  call rax
  mov  [x],eax
  ...

Yep… a referência ao símbolo sum está ali e pode ser um fator de decisão para o linker, mas existem outras formas de inicializar um ponteiro, não é? Além disso, o linker tende a ser agnóstico em relação à linguagem de programação usada para gerar os arquivos objeto e, por isso, não sabe das capacidades de cada linguagem. Sua tarefa é resolver nos identificadores (nomes de símbolos) e dar-lhes endereços…

Bem… Nem tudo está perdido… Uma das maneiras de evitar o inlining é codificar as funções em módulos separados. A função sum() poderia ficar num arquivo sum.c e a função f() em func.c, por exemplo. Compilamos ambos separadamente e o linker resolverá os nomes dos símbolos… Como são arquivos objetos diferentes, não há inlining automático.

A outra maneira é modificando o atributo da função, contida no mesmo módulo, que pode ser feito num protótipo, antes da definição da mesma:

__attribute__((noinline))
unsigned int sum(char *, size_t);

Ou então logo antes da definição. Nesses casos o compilador respeitará o atributo…

Uma terceira maneira, se a função não for usada por nenhuma outra função “extern”, é declará-la como static e também usar o atributo acima… Uma função marcada como static só é visível pelo próprio módulo e o compilador tende a torná-la inline, mas com o atributo ela passa a ser “offline” e invisível para os demais módulos (e, provavelmente, para o próprio linker – o nome será resolvido, em teoria, pelo próprio compilador).

Este é um método interessante se a sua função for usada apenas no módulo em que está contida e você não quer acabar com múltiplas cópias (uma “global” e várias “inline”). É ainda mais interessante porque, como ela não é perceptível pelo linker, vários módulos podem reusar o nome da função sem problemas de conflitos.

Dica pouco óbvia sobre otimização de código (funções inline)

Vou tentar aqui desfazer um conceito que é amplamente divulgados a respeito de organização de código para atingir “a melhor performance possível”. Trata-se da afirmação de que “códigos inline são mais rápidos porque não incorrem em chamadas e retorno”…

Embora seja verdade de códigos inline pequenos possam ser mais eficientes que códigos pequenos chamados, isso não é verdade para códigos com algum nível de complexidade. Sim, as instruções CALL e RET tomam tempo porque:

  1. Têm que manipular a pilha;
  2. Têm que verificar o nível de privilégio;
  3. Têm que verificar o nível de “proteção”.

Basta dar uma olhada na rotina equivalente das instruções CALL e RET no manual de desenvolvimento de software da Intel (2º volume) para ficar com certo receio de usá-las. No entanto, o processador é esperto o suficiente para não realizar as verificações 2 e 3, acima, se estamos lidando somente com o userspace ou o kernelspace. Essas verificações só acontecem no caso de mudança de privilégio ou características diferentes dos descritores de segmento selecionados (no modo i386) – ou dos descritores de páginas (ambos os modos).

Já o primeiro item tende a ser otimizado, graças ao cache e, ainda, a perda não é assim tão grande… CALL gasta cerca de 5 ciclos de clock e RET, 8. Mas, o detalhe é que essas instruções podem ser paralelizadas nas várias “portas” da unidade de execução. Claro, funções inline tendem a não usarem essas instruções e, quando digo “tendem” é porque marcar uma função como inline significa apenas oferecer uma dica ao compilador, que pode ou não ser obedecida.

De fato, funções com alguma complexidade podem ser bem mais lentas se forem usadas inline. O motivo? O cache L1! Já falei muito do cache aqui. O problema é que temos uma quantidade bem limitada de espaço nele e as funções inline tendem a fazer com que as rotinas chamadoras fiquem bem grandes que podem não caber no cache L1. Assim, ao tentar ler uma instrução que não está no cache o processador tem que “parar tudo”, trazer 64 bytes para o cache (uma linha) e continuar com a leitura das instruções. Esse “parar tudo” pode gastar centenas de ciclos, dependendo do caso.

Além da instrução RET e, talvez, prólogo e epílogo para manipular o stack frame, a rotina que não seja inline tende a ter o MESMO tamanho da rotina inline, mas ocupará apenas o seu próprio espaço, não sendo replicada por todo o código, diminuindo a pressão no cache L1 e, como consequência, aumentando a velocidade… Vale lembrar que não é apenas o seu código que está sendo executado, mas o código do sistema operacional, drivers, interrupções, etc. O cache L1 não é apenas do seu processo!

Neste ponto, é interessante tentar definir a fronteira do que seria a “alguma complexidade” que citei acima. Usei essa maneira genérica para tentar definir qualquer coisa que exija algum processamento mais ativo (loops, múltiplos testes etc). Por exemplo, as funções abaixo podem ser candidatas para serem inline:

int f(int x, int y)
{ return 3*x+y; }

int g(int x)
{ 
  if (x)
    return 3;
  return 0;
}

Mas, considero rotinas como essa outra indignas da promoção à inline:

unsigned int sum(char *bufptr, size_t size)
{
  unsigned int s = 0;

  while ( size-- )
    s += *bufptr++;

  return s;
}

O motivo é simples… Funções de baixa complexidade costumam usar poucas instruções. As funções f() e g() acima provavelmente serão compiladas como:

f:
  lea eax,[rsi+rdi*2]
  add eax,edi
  ret

g:
  xor  eax,eax
  mov  ecx,3
  test edi,edi
  cmovnz eax,ecx
  ret

Ou algo semelhante… A primeira rotina, ignorando o RET, tem 5 bytes de tamanho, a segunda, 12 e, em minha opinião, qualquer coisa com menos de 16 bytes pode (mas, não necessariamente deve) ser promovido a inline. Comparemos agora o código da terceira rotina “simples”, acima:

sum:
  test rsi, rsi
  je   .L9
  add  rsi, rdi
  xor  eax, eax
.L8:
  add	rdi, 1
  movsx edx, BYTE [rdi-1]
  add  eax, edx
  cmp  rsi, rdi
  jne  .L8
  ret
.L9:
  xor  eax, eax
  ret

Ignorando os RETs a rotina gasta 32 bytes do cache L1. Metade de uma linha e 2 nívels de associatividade… Não é lá grandes coisas, mas considere que múltiplas “chamadas” colocam \frac{1}{4} de linha de pressão no cache por chamada. Como uma única função CALL gastará apenas 5 bytes (0xE8 seguido do deslocamento relativo de 32 bits), gastar 32 bytes por “chamada” é claramente um exagero desnecessário.

Neste aspecto a função f() é uma séria candidata para inlining, afinal 5 bytes do CALL contra 5 bytes sem CALL não é algo difícil de decidir. A função g() por outro lado é mais complicada… Minha escolha por 16 bytes é devido ao alinhamento automático que o compilador faz com relação ao início de cada função, mas, é claro, você pode decidir por um tamanho maior “mínimo”, desde que ele não ultrapasse os 64 bytes e/ou cruze várias linhas do cache.

Você, provavelmente, não quer ficar medindo o tamanho das suas funções em bytes (e nem precisa fazer isso, basta pedir pro linker gerar um mapa), mas é mais prático usar um critério de “complexidade” subjetiva, como fiz acima…

“Receitas de bolo” não costumam ser boas ideias…

Existem algumas regras de otimização que são aplicáveis a arquiteturas específicas em condições específicas e em ambientes específicos. Recentemente um de meus leitores me perguntou sobre algumas “regrinhas” de otimização para serem usadas em C que, infelizmente, tive que refutar (de maneira rasteira e, aqui, apresento mais detalhes).

Arqumentos de funções “por referência” versus “por valor”

A ideia aqui é que passar uma estrutura por valor para uma função é bem mais lento do que passá-la por referência, ou seja, via um ponteiro… Geralmente isso é verdade, mas, nem sempre… Considere os códigos abaixo:

#include <math.h>

struct vec3_T { double x, y, z; };

double vec3_length( struct vec3_T v )
{
  return sqrt( v.x * v.x + 
               v.y * v.y + 
               v.z * v.z );
}

double vec3_length2( struct vec3_T *pv )
{
  return sqrt( pv->x * pv->x + 
               pv->y * pv->y + 
               pv->z * pv->z );
}

No primeiro caso os 24 bytes da estrutura vec3_T terão que ser copiados para a pilha antes da chamada da função vec3_length(). No segundo caso, a função supõe que dos dados já estejam em algum lugar na memória e apenas a referência pv é usada para localizá-los. Mas, olhando para o código em assembly para a arquitetura x86-64 com boa otimização, temos:

vec3_length:                 vec3_length2:
  sub rsp, 24                         
                                      
  movsd xmm1, [rsp+32]         movsd xmm1, [rdi]
  movsd xmm2, [rsp+40]         movsd xmm2, [rdi+8]
  mulsd xmm1, xmm1             mulsd xmm1, xmm1
  movsd xmm0, [rsp+48]         movsd xmm0, [rdi+16]
  mulsd xmm2, xmm2             mulsd xmm2, xmm2
  mulsd xmm0, xmm0             mulsd xmm0, xmm0
  addsd xmm1, xmm2             addsd xmm1, xmm2
  pxor  xmm2, xmm2             pxor  xmm2, xmm2
  addsd xmm0, xmm1             addsd xmm0, xmm1

  ucomisd xmm2, xmm0           ucomisd xmm2, xmm0
  sqrtsd  xmm1, xmm0           sqrtsd  xmm1, xmm0
  ja  .L5                      ja  .L12
.L1:                                  
  movapd  xmm0, xmm1           movapd  xmm0, xmm1
  add rsp, 24                           
  ret                          ret 

.L5:                         .L12:
  movsd [rsp+8], xmm1          sub rsp, 24
  call  sqrt@PLT               movsd [rsp+8], xmm1
  movsd xmm1, [rsp+8]          call  sqrt@PLT
  jmp .L1                      movsd xmm1, [rsp+8]
                               add rsp, 24
                               movapd  xmm0, xmm1
                               ret

Exceto pelo ajuste do stack frame e pela verificação da precisão de sqrtsd, as rotinas são essencialmente as mesmas. E a cópia da estrutura só será feita se esta não estiver já na pilha.

O que quero mostrar que essa afirmação categórica de que “por referência” é sempre melhor é falsa. Bem como afirmações que “passagem por referência” tendem a usar registradores quando “por valor” não também são falsas. Isso depende da convenção de chamada em uso e do escopo da função (funções estáticas tendem a ser “otimizadas” de forma diferente e muitas vezes são promovidas para “inline”).

Funções inline são mais “rápidas”

Essa é outra receita de bolo que pode ser perigosa para a boa performance. Em primeiro lugar, o modificador “inline” é apenas uma dica dada ao compilador. Este pode respeitá-la ou não. Ou seja, não há garantia de que sua função “inline” seja, de fato, “inline”… Em segundo lugar há o problema do cache L1.

Tanto seus dados quanto as instruções devem estar no cache L1 para que possam ser rapidamente acessados pelo circuito de decodificação e execução do processador. O cache L1 é limitado e dividido em dois grandes blocos: L1D e L1I. Cada um com 32 KiB divididos em 512 linhas de 64 bytes. Se tivermos muitas “funções inline”, é evidente que o tamanho de nossos códigos ficarão maiores e isso poderá, para uma única função, extrapolar os 32 KiB do cache L1I e fazer com que linhas do cache LII precisem ser carregadas com mais frequência do que o necessário.

A regra a ser seguida deveria ser: Funções estratégicas e pequenas merecem o estudo para que sejam promovidas para “inline”, desde que não poluam em demasia o cache L1I.

Arrays unidimensionais e de múltiplas dimensões

Outro ponto errado é a crença que, ao lidar com arrays unidimensionais teremos performance melhor que lidarmos com arrays multidimensionais. Qualquer leitura de livros sobre algoritmos vai te mostrar que as dimensões superiores são apenas deslocamentos num array unidimensional equivalente. Por exemplo, se tivermos um array definido como:

int a[3][3];

E fizermos:

a[1][2]=0;

Isso é equivalente a declararmos um array unidimensional de 9 elementos e efetuarmos um pequeno cálculo para localizar a posição correta:

int a[9];
a[1*3+2]=0;

O compilador sabe que tem que multiplicar a dimensão mais alta pela quantidade de itens na dimensão mais baixa para obter o deslocamento inicial correto para ser adicionado ao offset da dimensão mais baixa… Um exemplo esclarece: Suponha que tenhamos um array de dimensões 3\times7:

int a[3][7];

Obviamente que um array unidimensional do mesmo tamanho terá 21 elementos (3\times7). A primeira linha do array bidimensional terá 7 elementos e teremos 3 linhas deste tamanho… Assim, para locarlizar um elemento no array unidimensional equivalente teremos pos=l\cdot7+c, onde l é a linha e c a coluna da “matriz”. A posição pos é a do array unidimennsional equivalente… Num código do tipo:

int a[3][7];

void zero(int l, int c) { a[l][c] = 0; }

O compilador gera um código parecido com esse:

zero:
  lea   rdx, [a]
  movsx rdi, edi
  movsx rsi, esi
  lea   rax, [rsi+rdi*8] ; pos = l*8+c
  sub   rax, rdi         ; pos -= l
  mov   DWORD [rdx+rax*4], 0
  ret

Aqui o compilador escolheu não usar a instrução de multiplicação para poupar ciclos (era de se supor que fizesse, já que 7 não é fatorável em 2^n). Esse tipo de otimização será feita sempre que possível pelo compilaor, gerando um código bem eficiente.

Outro ponto é notar que a construção abaixo é diferente de um array multidimensional:

int *a[3];

Aqui temos um array de 3 ponteiros para int e, como cada ponteiro pode apontar para um array, teremos um array de 3 posições, unidimensional, cujos itens podem apontar para outros arrays unidimensionais de inteiros… Essa construção pode ser até melhor que a de arrays multidimensionais simples, em termos de performance, em muitos casos, já que a multiplicação do índice da dimensão mais alta pela quantidade de itens da dimensão mais baixa é, sempre que possível, evitada.

Loops “de trás para frente” são mais rápidos do que os “da frente para trás”

Essa crença é falsa no sentido de que o compilador tenta encontrar o melhor arranjo possível de instruções para atender a instrução do loop. Por exemplo, os loops abaixo podem resultar no mesmo conjunto de instruções em assembly:

for (i = 0; i < N; i++)
  a[i] = 0;

for (i = N - 1; i >= 0; i--)
  a[i] = 0;

O compilador saberá, por análise, que ambos os loops fazem a mesma coisa e criará o melhor código possível para ambos os casos. Isso é especialmente verdadeiro se algum nível de otimização estiver sendo usado (-O2 em diante no GCC, por exemplo).

O que é verdadeiro nessa “receita” é que, em assembly, em alguns casos, loops “para trás” podem ser melhores pelo fato de que a comparação estar sendo feita contra o valor zero. Mesmo assim, como já mostrei por aqui, a posição onde a comparação é feita também é importante para aproveitar os efeitos do branch prediction.

Aliás, em alguns compiladores, o código acima poderia muito bem não conter loop algum, sendo substituído por:

  mov ecx,N
  xor eax,eax
  lea rdi,[a]
  rep stosd

Pode-se argumentar que rep stosb é uma forma de loop, mas note que ele preenche o buffer apontado por RDI “da frente para trás”, o que quebra a “receita”…

Variáveis locais tendem a serem alocadas em registradores

Essa receita também deve ser avaliada cuidadosamente. Ela tende a ser verdadeira em duas condições:

  1. Se houverem registradores em número suficiente para essa “alocação”;
  2. Se estivermos compilando o código com algum nível de otimização.

O primeiro ponto é óbvio, se nossa rotina já estiver usando os registradores “alocáveis” disponíveis, na convenção de chamada, então não será possível alocar algum outro e a pilha será usada… Já o segundo ponto, a rotina abaixo pode ilustrá-lo:

int sum( int *a, unsigned size )
{
  int s;

  s = 0;
  while ( size-- )
    s += *a++;

  return s;
}

Compilando o código sem e com otimização (-O2), temos:

sum:                        sum:
  push  rbp                   test  esi, esi
  mov   rbp, rsp              lea   eax, [rsi-1]
  mov   [rbp-24], rdi         je    .L4
  mov   [rbp-28], esi         lea   rdx, [rdi+rax*4+4]
  mov   DWORD [rbp-4], 0      xor   eax, eax
  jmp   .L2                 .L3:
.L3:                          add   eax, [rdi]
  mov   rax, [rbp-24]         add   rdi, 4
  lea   rdx, [rax+4]          cmp   rdi, rdx
  mov   [rbp-24], rdx         jne   .L3
  mov   eax, [rax]            ret
  add   [rbp-4], eax        .L4:
.L2:                          xor   eax, eax
  mov   eax, [rbp-28]         ret
  lea   edx, [rax-1]
  mov   [rbp-28], edx
  test  eax, eax
  jne   .L3
  mov   eax, [rbp-4]
  pop   rbp
  ret

O código da esquerda (compilado sem otimizações) mantém na pilha a variável sum, bem como o ponteiro a e o argumento size que, por definição, são “variáveis locais”… Já a rotina da direita, os mantém em registradores (sum é mantido em EAX e o ponteiro final é mantido em RDX enquanto a é mantido em RDI.

Existem outros fatores em jogo aqui… variáveis locais de tipos primitivos e ponteiros tendem a serem colocados em registradores, se as otimizações estiverem sendo usadas, mas arrays não, especialmente se estes ultrapassarem certa quantidade de elementos.

“Eu uso o JDK da Oracle porque é free!”… ã-hã! tá… sei…

Já leu o acordo de licenciamento, figura? Tá aqui, ó. Pontos altos:

You may not:
- use the Programs for any data processing or any commercial,
  production or internal business purposes other than developing,
  testing, prototyping and demonstrating your Application;
- create, modify, or change the behavior of, classes, interfaces,
  or subpackages that are in any way identified as "java", "javax",
  "sun", “oracle” or similar convention as specified by Oracle in
  any naming convention designation.

A primeira é importante porque diz, claramente, que para usar O JDK (ou JRE) da Oracle você tem que dar uma grana para a Oracle…