Otimizando a interoperabilidade entre Lua e C++

Introdução
O motor do Roblox é escrito em uma combinação de C++ e Lua, com o código que realiza operações computacionalmente intensivas escrito em C++ otimizado, enquanto a lógica do jogo e os scripts são escritos em Lua, para facilitar o desenvolvimento. Para que esse modelo seja eficaz, é necessário que as transições entre Lua e C++ sejam o mais rápidas possível, já que qualquer tempo gasto nessa “terra de ninguém” é essencialmente apenas milissegundos desperdiçados.
Nos últimos dois meses, implementamos várias melhorias nessa parte do sistema. Uma parte específica — a invocação real de métodos C++ a partir do Lua — foi particularmente interessante, pois levou a melhorias consideráveis de velocidade e exigiu uma investigação profunda no interior do Lua para entender como as coisas funcionavam nos bastidores.
Acabamos modificando a própria VM do Lua, mas antes de chegarmos a isso, precisamos estabelecer algumas bases.
Compiladores, VM e bytecode
Quando o código-fonte do Lua é compilado, ele é transformado em bytecode do Lua, que a VM do Lua executa em seguida. O bytecode do Lua tem cerca de 35 instruções no total, para tarefas como ler/gravar tabelas, chamar funções, realizar operações binárias, saltos e condicionais, e assim por diante. A VM do Lua é baseada em registros, ao contrário de muitas outras VMs, que são baseadas em pilha; portanto, parte do que o compilador faz ao gerar bytecode é determinar quais registros cada instrução deve usar.
Cada instrução tem o formato “OP_CODE A B” ou “OP_CODE A B C”, onde “OP_CODE” é o código de operação (por exemplo, CALL para chamar uma função) e A/B/C são os argumentos do código de operação. Os argumentos (ou registros) não são valores reais. Em vez disso, são índices que apontam para uma de duas tabelas: a tabela de constantes (escrita como Kst(..)) ou a tabela de registros (escrita como R(..)).
Para uma descrição detalhada do bytecode do Lua, consulte “Uma introdução simples às instruções da VM do Lua 5.1”. É muito mais empolgante do que parece; prometo!
Para você ter uma ideia de como é o bytecode do Lua, vamos examinar alguns programas simples primeiro e depois avançar para exemplos mais relevantes.
Usando o utilitário Chunkspy, podemos desmontar o bytecode do Lua em assembly do Lua e obter uma listagem do código, bem como a tabela de constantes, para que possamos ver essencialmente qual bytecode é gerado para qualquer código-fonte do Lua.
Exemplos básicos de bytecode
Um programa simples como “x = 10” é compilado em:
.const "x"; 0
.const 10; 1
[1] loadk 0 1 ; 10
[2] setglobal 0 0 ; x As duas primeiras linhas mostram a tabela de constantes (com o valor da string “x” no slot 0 e o valor inteiro 10 no slot 1), e as duas linhas seguintes são os opcodes desmontados.
[Linha 1] Ao consultar o opcode LOADK no “No Frills”, vemos que ele tem a forma “LOADK A Bx --- R(A) := Kst(Bx)”. Portanto, LOADK tem dois argumentos (registros A e B) e sua operação consiste em atribuir o valor encontrado na tabela de constantes no slot indicado pelo segundo registro, Kst(Bx), ao registro R no slot indicado pelo primeiro argumento, R(A). “Bx” significa apenas que, como o código de operação tem apenas dois argumentos, o registro B é estendido e recebe mais bits.
[Linha 2] SETGLOBAL tem a forma “SETGLOBAL A Bx --- Gbl[Kst(Bx)] := R(A).” Ele atribui um valor à tabela global usando a chave fornecida pela tabela de constantes no slot do segundo argumento. Como o segundo argumento é 0 e o valor da tabela de constantes em 0 é “x”, ele grava algo na tabela global usando a chave “x”. O que quer que esteja na tabela de registros no slot indicado pelo primeiro argumento é o que está sendo gravado, o que a instrução anterior carregou com o valor 10.
Vamos ver um exemplo um pouco mais complicado: “x = 10; y = x”. Vou deixar a execução manual do código como exercício para o leitor. :)
.const "x"; 0
.const 10; 1
.const"y"; 2
[1] loadk 0 1 ; 10
[2] setglobal 0 0 ; x
[3] getglobal 0 0 ; x
[4] setglobal 0 2 ; y
Bytecode para chamadas de função
Vamos dar uma olhada no código gerado para “foo(10):”
.const "foo"; 0
.const 10; 1
[1] getglobal 0 0 ; foo // R(A) := Gbl[Kst(Bx)]
[2] loadk 1 1 ; 10 // R(A) := Kst(Bx)
[3] call 0 2 1
Para executar chamadas de função, a função deve ser carregada no primeiro registro e os argumentos nos registros subsequentes. A semântica para “CALL A B C” é tal que A contém a função, B é o número de argumentos (na verdade, é o número de argumentos +1, devido à forma como “...” é implementado) e C é o número de valores de retorno (novamente, é o número de valores de retorno +1, para lidar com múltiplos valores de retorno).
Estamos familiarizados com as duas primeiras linhas; elas carregam um valor no slot 0 da tabela de registros e o valor 10 no slot 1 da tabela de registros. A terceira linha é a que executa a chamada de função, usando o valor no registro A (slot 0 da tabela de registros, que foi carregado com “foo”), com B especificando o número de argumentos e C o número de valores de retorno (lembre-se, tanto B quanto C devem ter 1 adicionado aos seus valores). Antes que a função possa ser chamada, a VM também verifica se o valor em R(A) é de fato chamável.
O Lua possui um mecanismo que permite aos usuários estender a funcionalidade das tabelas associando uma metatabela a uma tabela existente. A metatabela contém métodos de fallback que são invocados se um determinado método ou operação não puder ser executado na tabela principal (consulte https://www.lua.org/pil/13.html para uma descrição completa).
Para nossos propósitos, as entradas mais relevantes na metatabela são os campos “__index” e “__call”. O __index é usado ao procurar um elemento em uma tabela, de modo que o código “local x = my_table[10]” chamaria primeiro o método __index na my_table. Se isso falhasse, ele tentaria, em vez disso, chamar __index na metatabela de my_table. __call é usado de forma semelhante quando você tenta tratar algo como uma função e chamá-la “local x = foo(42)”, por exemplo
Para que Lua e C++ possam interoperar, eles precisam de alguma forma de compartilhar funções e dados. O Lua facilita isso fornecendo um tipo de dados chamado UserData. Objetos UserData podem ser criados no ambiente C++ e, como são tipos de dados nativos do Lua, podem ser adornados com metatabelas que permitem que o código Lua interaja com eles como se fossem objetos Lua comuns.
Chamadas de funções de membro
Ok, voltemos a examinar um pouco de bytecode! O próximo exemplo é um pouco mais interessante porque mostra o que acontece quando você tem um código como “foo:bar(10),” que está chamando o método bar na instância foo (uma instância da classe Foo).
foo:bar(10)
.const "foo"; 0
.const "bar"; 1
.const 10; 2
[1] getglobal 0 0 ; foo
[2] self 0 0 257 ; "bar"
[3] loadk 2 2 ; 10
[4] call 0 3 1
A novidade aqui é a instrução self [linha 2], que ainda não vimos antes. Self tem a sintaxe “SELF A B C --- R(A) := R(B)[RK(C)]; R(A+1) := R(B)”, então vamos analisar isso. Na tabela de registros, no slot R(A), ela colocará o resultado da consulta à tabela no slot de registro R(B) usando a chave no slot RK(C). Ela também copiará o que quer que estivesse no slot R(B) para o slot R(A+1), mas falaremos mais sobre isso depois. Você pode notar que o valor do registro C é 257. Isso é válido porque o Lua está usando RK(C) para consultar o valor, e RK usará a tabela de registros ou a tabela de constantes, dependendo do valor do 9º bit. Se for um 1, como é o caso aqui, então a tabela de constantes é usada; caso contrário, a consulta vai para a tabela de registros (após mascarar o bit mais alto).
A linha 3 coloca 10 no slot 2 e, finalmente, a linha 4 executará a chamada de função.
A instrução SELF tem duas finalidades. Primeiro, ela procura o método “bar” na classe Foo e o coloca em R(A). Em segundo lugar, como foo é um método de instância e precisamos da instância da classe na qual estamos invocando o método ao fazer a chamada, ela coloca essa instância em R(A+1). Se você está familiarizado com classes em Python, então talvez reconheça o conceito: métodos são geralmente escritos como “def my_method(self, arg1, arg2..),” onde self é a instância da classe.
Precisaremos nos aprofundar um pouco mais nisso e dar uma olhada no que acontece quando a instância foo é um objeto C++, representado em Lua como um objeto UserData.
A chamada SELF pode ser vista como uma consulta a uma tabela, ou seja, Foo[“bar”] (Foo maiúsculo representa a classe Foo, em oposição a foo, a instância), e sabemos que as consultas usarão o método __index. Quando a instância foo foi criada no ambiente C++, uma metatabela foi associada à instância, e o campo __index dessa metatabela foi definido como um trecho de código C++ que será chamado quando __index for invocado.
Quando C/C++ é chamado a partir do Lua, o único dado que é passado é um objeto lua_State. Esse objeto contém tudo relacionado à thread do Lua em execução no momento. A informação mais importante no objeto de estado é a pilha do Lua, que contém os argumentos da função (acessados por meio da família de funções lua_tointeger/tostring etc.) e também é usada para retornar valores de volta ao Lua.
Em pseudo-C++, nossa função __index fica mais ou menos assim:
int metaIndex(lua_State* L)
{
// first argument is the userdata object
UserData* userdata = lua_touserdata(L, 1);
// get some kind of descriptor, that contains information
// about what methods the class exposes
ClassDescriptor* desc = getDescriptorForUserData(userdata);
// See if the class has the requested method
const char* methodName = lua_tostring(L, 2);
MemberFunctionPtr method = desc->hasMethod(methodName);
if (method)
{
// Upvalues are values that are available when a C
// function is invoked.
lua_pushupvalue(L, method);
lua_pushcfunction(L, methodInvoker);
return 1;
}
else
{
lua_pushnil(L);
return 0;
}
}
Muitos detalhes internos foram omitidos, mas aqui está o essencial. Considerando que o objeto UserData é passado como o primeiro argumento na pilha Lua, conseguimos encontrar um descritor que descreve a classe C++ real e, por meio desse descritor, podemos verificar se essa classe possui um método com o nome fornecido. Se houver, um ponteiro de função para um invocador de método é inserido na pilha Lua, e retornamos sucesso.
Após essa chamada, a VM Lua colocará o restante dos argumentos na tabela de registros e, em seguida, chamará a função que retornamos do método metaIndex, que novamente chamará o C++ e chegará à função invocadora:
int methodInvoker(lua_State* L)
{ <br> // Get the userdata and the class descriptor
UserData* userdata = lua_touserdata(L, 1);
ClassDescriptor* desc = getDescriptorForUserData(userdata);
Class* instance = (Class*)userdata;
// Using Lua's upvalue mechanism, get the 'method'
// that was stored in metaIndex.
MemberFunctionPtr method = lua_getupvalue(L, 1);
// This is hand-wavey, but we have some mechanism of being
// able to invoke a member function via the class descriptor,
// and also pop arguments from the Lua stack, and push return values
return desc->invokeFunction(instance, method, L);
}
O methodInvoker também usa o ClassDescriptor, mas desta vez ele é capaz de invocar a função membro e retirar os argumentos corretos da pilha.
A reta final!
Agora que podemos ver claramente as duas idas e voltas do Lua para o C++, podemos tentar descobrir como otimizar isso.
Nosso objetivo final é fazer uma única chamada de função do Lua para o C++ e ter todas as peças necessárias na pilha do Lua para poder fazer a busca e a invocação do método de uma só vez. O problema parece ser que nos falta um registro. Quando chamamos nossa função combinada de pesquisa/invocação, queremos que a pilha do Lua tenha a forma [self, nome do método, arg1, arg2, ...], mas, observando o SELF, vemos que ele usa seu primeiro slot para o resultado da pesquisa da função do método e o segundo slot para armazenar a instância.
Uma descoberta importante surgiu quando analisamos como o metamétodo __call funcionava. Se um objeto possui o metamétodo __call, então, antes que a função _call seja invocada, o próprio objeto é empurrado para a pilha e todos os argumentos são deslocados para cima. Aproveitando essa funcionalidade, havia uma maneira de colocar “self” na pilha sem precisar armazená-lo explicitamente em um registro.
A segunda parte envolvia colocar o nome do método na pilha também. Para isso, tivemos que ser astutos e alterar o funcionamento do código de operação SELF.
Lembre-se de que, no caso padrão, SELF tentaria encontrar a função membro e armazená-la em R(A) junto com a instância em R(A+1). Acabamos pulando a busca por completo e armazenamos o objeto real em R(A) e o nome do método em R(A+1).
Se agora garantíssemos que o objeto em R(A) tivesse um metamétodo __call, acabaríamos também empurrando self para a pilha. Assim, teríamos uma pilha que se parecia com [self, nome do método, args…] e fizemos apenas uma única chamada para C++. Perfeito! Bem, quase. :)
Antes de considerar isso concluído, queríamos dar alguns retoques finais. Não queríamos sobrecarregar a semântica do metamétodo __call; então, em vez disso, adicionamos um metamétodo específico para esse tipo de invocação — chamado __namecall — que estava disponível apenas em objetos UserData. Também modificamos o opcode SELF para que ele use a nova semântica apenas se o objeto tiver um metamétodo __namecall.
A segunda coisa que fizemos foi, principalmente, tornar o novo caminho e o caminho antigo capazes de compartilhar código facilmente. Em vez de ter o nome do método como o segundo argumento, nós o colocamos no último argumento. Assim, depois de ter sido usado para procurar o ponteiro do método, ele poderia ser facilmente removido da pilha, e a pilha ficaria como se a função tivesse sido invocada pelo caminho antigo.
Conclusão
Qual é o impacto dessa otimização? Bem, como a maioria das coisas na programação, a resposta é “depende”. Para funções pesadas — e que você não chama com frequência —, você não verá muita melhora. Mas para funções menores que você chama com frequência, a economia pode ser considerável.
Os usuários do Fórum de Desenvolvedores rapidamente perceberam o surgimento desse novo e estranho metamétodo, e foi apresentada uma tabela comparando a velocidade do __namecall tanto com o método antigo de chamar métodos de instância quanto com uma solução alternativa que os desenvolvedores vinham usando para otimizar a chamada de métodos:
local part = workspace.Baseplate
local count = 1000000
local start0 = tick()
for i=1,count do
part:IsA("BasePart")
end
local end0 = tick()
local start1 = tick()
for i=1,count do
local isa = part.IsA
isa(part, "BasePart")
end
local end1 = tick()
local start2 = tick()
local isa = part.IsA
for i=1,count do
isa(part, "Basepart")
end
local end2 = tick()
print("namecall", end0 - start0)
print("index+call", end1 - start1)
print("call", end2 - start2)
> namecall 0.49229717254639
> index+call 0.78510332107544
> call 0.49960780143738
O primeiro loop usa o novo caminho de código do __namecall, mas como toda a mágica acontece nos bastidores, não há necessidade de os desenvolvedores alterarem nenhum código existente para se beneficiarem da otimização.
O segundo loop emula a maneira antiga de fazer uma chamada de método de instância; primeiro fazendo uma pesquisa para encontrar o método e, em seguida, invocando-o.
E, finalmente, o terceiro loop mostra uma otimização comum que os desenvolvedores costumavam fazer, na qual o método era primeiro pesquisado, armazenado em uma variável local e, em seguida, a variável era invocada.
O bom aqui é que isso mostra que, com a otimização do __namecall, não é mais necessário armazenar explicitamente funções de instância em cache, já que é tão rápido quanto a otimização com cache; portanto, o código mais direto também será o de melhor desempenho.
Agora que o __namecall foi implementado e estamos satisfeitos com os resultados que estamos vendo, é hora de voltar nosso foco para o uso de memória e ver o que podemos fazer para melhorar o cliente nessa área!


