Verificações de nulos implícitos são uma técnica para remover verificações explícitas para referências/pontos nulos em uma representação nativa de linguagens de alto nível e, em vez disso, confiar no processador emitindo uma violação de acesso, que é então manipulada (ou seja, SEH) e traduzida em uma exceção gerenciada. É usada principalmente em casos em que a sobrecarga de manipulação de exceções é secundária, por exemplo, se sabemos que exceções nulas são raras.
Em todos os exemplos que encontrei, essas verificações são feitas para instruções que fazem um acesso ao ptr em questão:
int m(Foo foo) {
return foo.x;
}
Aqui, poderíamos simplesmente emitir o código asm:
mov rax,[rcx]
E fazer com que o mecanismo nativo de tratamento de exceções lide com a geração de uma NullReferenceException, em vez de travar.
Mas e quanto às chamadas de função?
int m(Foo foo) {
return foo.MemberFunction();
}
É possível usar verificações de nulos implícitas lá também? Estou interessado especificamente em x64-asm. Parece mais difícil lá. Vamos dar uma olhada em uma chamada de função não virtual exemplar em asm (o código não corresponde à função 1:1, ele contém um "mov" apenas para mostrar que um objeto é configurado no registro usado para uma chamada de função de membro no Windows):
mov rcx,[rsp+20h] // load target-object from stack-local (Foo*)
call Foo::MemberFunction // call Foo::MemberFunction, can be represented with an address w/o fixups of the ptr
Aqui, não temos nenhum acesso à memória para a qual "rcx" aponta. Então, se por definição da linguagem, tal chamada deve lançar uma NullReferenceException no call-site, precisaríamos usar verificações explícitas:
mov rcx,[rsp+32h] // load target-object from stack-local (Foo*)
test rcx,rcx
je .L0 // exception-handler already moved out of hot-path
call Foo::MemberFunction // call Foo::MemberFunction, can be represented with an address w/o fixups of the ptr
...
.L0:
call throwNullReferenceException();
Ou existe alguma maneira mais eficiente de substituir o par test+je por uma instrução, que gera uma violação de acesso? Eu estava pensando que poderia fazer
mov rcx,[rsp+32h] // load target-object from stack-local (Foo*)
mov rax,[rcx] // mov into unused reg, to trigger access-violation
call Foo::MemberFunction // call Foo::MemberFunction, can be represented with an address w/o fixups of the ptr
Isso não usaria nenhuma ramificação e não exigiria uma chamada adicional para uma invocação de exceção. No entanto, ele potencialmente precisaria ler a memória de [rcx], o que não é necessário no outro método. Como isso funciona em comparação com a ramificação? Se for pior, há alguma maneira que seja melhor? Veja abaixo para mais explicações do caso de uso completo.
Fundo
Tenho uma linguagem de alto nível personalizada, que é compilada para bytecode e, em seguida, para ASM nativo. A linguagem lida com verificações de nulos graciosamente com exceções de NullReference. Exceções ainda são sempre erros que precisam ser abordados , e não algo que ocorre normalmente. Portanto, o código para lidar com exceções pode ser ineficiente. O importante é que o código seja executado o mais rápido possível, dado o caso comum de nenhuma exceção (portanto, nenhuma referência nula). É por isso que verificações de nulos implícitas parecem atraentes. Remover todas as ramificações e código adicional necessário para lidar com a exceção para chamadas pode ser benéfico. No entanto, mesmo as verificações existentes já devem ser rápidas. A ramificação deve ser bem previsível para ser sempre falsa, e eu já fiz isso para que este caso não exija um jmp, mas tenha o código executado linearmente (o que eu li ser mais otimizado).
Então, dado isso, minha tentativa de me livrar desses cheques no caso que mencionei é tola ou há alguma maneira de fazer isso de forma otimizada?
Isso é barato, a menos que falte no cache. E o chamado pagaria esse custo mais tarde de qualquer maneira, a menos que nunca toque em seu
*this
.Se isso acontecer, então o carregamento anterior foi basicamente uma pré-busca... a menos que o primeiro acesso "real" tenha sido uma gravação, caso em que poderíamos ter ido direto para o estado MESI Exclusive / Modified, sem primeiro obter uma cópia que está apenas no estado Shared de um acesso somente leitura, se vários threads acessarem esse objeto. Se nenhum outro núcleo possuía a linha de cache antes, um carregamento simples normalmente colocará a linha de cache no estado Exclusive, que pode fazer a transição para Modified sem outra transação off-core (um Read For Ownership = RFO).
Se o chamado também fizer uma leitura como primeiro acesso, não há desvantagens em ler aqui.
Com um objeto grande, se a função membro tocasse apenas em membros que estivessem em linhas de cache posteriores, tocar na primeira linha de cache poluiria o cache.
Deixar um exec fora de ordem lidar com uma carga é ótimo, provavelmente mais barato do que um teste/ramificação assumindo que ele nunca realmente falha. Um teste/ramificação estaria sujeito a previsões erradas de ramificação. Como para cada instrução, o pipeline assume fortemente que as cargas não falharão, apenas fazendo algo se uma instrução com falha atingir a aposentadoria (tornar-se não especulativa).
Mas os ramos são sempre previstos de uma forma ou de outra, e consomem recursos de previsão de ramos e podem criar alias para outros ramos, de modo que são previstos incorretamente, mesmo que sempre sejam fortemente rejeitados.
CPUs x86 modernas têm um ótimo throughput de porta de carga, como 2 ou 3 por ciclo de clock (com acertos de cache L1d para cargas naturalmente alinhadas) e números bastante generosos de buffers de carga para rastrear cargas pendentes. Por exemplo, Haswell de mais de 10 anos atrás ( https://www.realworldtech.com/haswell-cpu/5/ ) tem 72 entradas de buffer de carga para um ROB (ReOrder Buffer) de 192 entradas.
Cargas em um registrador de 32 bits com
mov eax, [rcx]
(2 bytes de código de máquina) oumovzx eax, byte ptr [rcx]
(3 bytes) para estruturas menores que 4 bytes são provavelmente sua melhor aposta, até mais barata do que a sugestão de @user555045 detest
com um imediato0
. O encaminhamento de armazenamento de 8 bytes para cargas de 4 bytes dos primeiros 8 bytes é eficiente em CPUs x86, mesmo com muitos anos de idade, e o tamanho do operando de 32 bits evita um prefixo REX.test [rcx], cl
economizaria tamanho de código e ainda não teria uma dependência falsa, masmov
também evitaria um ALU uop para as unidades de execução de back-end. Deveria ser apenas 1 micro-op (uop) para o front-end e emitir/renomear estágios em qualquer CPU que faça qualquer tipo de microfusão. (Ou a AMD simplesmente decodificando-o como 1 uop em primeiro lugar).Ambas as principais convenções de chamada x86-64 têm pelo menos um reg puramente sobrecarregado por chamadas, no qual é sempre seguro carregar antes de uma chamada (por exemplo, EAX para Win x64, R11D para AMD64 SysV : funções variádicas usam AL para passar o número de argumentos XMM. Embora você possa simplesmente defini-lo como uma constante depois
mov
no EAX, a menos que seja um shim/trampolim que passa argumentos variádicos para outra função).Escrever um GPR e/ou FLAGS é equivalente em termos de limites de arquivo de registro sobre o quão longe o exec fora de ordem pode ver à frente : uma entrada de arquivo de registro físico tem espaço para um inteiro de 64 bits mais um resultado FLAGS, então instruções como essas
add rax, rcx
podem ser tratadas como se estivessem escrevendo apenas um resultado pela máquina exec fora de ordem.test cl, [rcx]
outest eax, [rcx]
são apenas um pouco piores quemov eax, [rcx]
, então não se preocupe muito em usá-los se por algum motivo você não conseguir escolher facilmente um registro para escrever.