O conteúdo deste site foi traduzido usando inteligência artificial (IA) ou tecnologia de tradução automática e pode conter erros.

Skip to content

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)

{ &nbsp;  <br>  &nbsp; &nbsp;    &nbsp;//  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-&gt;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)



&gt;  namecall  0.49229717254639

&gt;  index+call  0.78510332107544

&gt;  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!