Introdução

Inlining é um recurso utilizado pelos compiladores para otimizar certas chamadas de função. Esse recurso é facilmente confundido com o especificador de função inline. Apesar da semelhança dos nomes, o recurso não tem uma relação direta com o especificador.

Segundo o C99 Standard, uma função declaradas com o especificador inline é uma inline function. Tornar uma função uma inline function sugere que chamadas a essa função aconteçam o mais rápido possível. Contudo, compiladores modernos ignoram essa sugestão, pois eles são otimizados a ponto de tomarem decisões melhores do que o programador. Na prática, o especificador inline é usado para aprimorar a forma com que o compilador lida com a otimização de funções, como será discutido neste artigo.

Desde que foi introduzido formalmente no C99, não houve modificações quanto ao uso do inline até o C17, a revisão mais atual da linguagem no momento em que escrevo. As discussões desse artigo são referem-se estritamente à linguagem C. Apesar das semelhanças, em C++ o inline é tratado de forma diferente.

Compiladores e a otimização de chamadas de função

Uma forma que os compiladores têm de acelerar a execução de uma chamada de função, é fazendo o inlining, que é algo equivalente a substituir a chamada da função pelo próprio código da função em si. Isso elimina completamente o overhead da chamada de função, pois efetivamente não há nenhuma chamada. Isso é especialmente benéfico para funções curtas que são chamadas muitas vezes durante a execução de um programa.

O inlining também possibilita outras otimizações por parte do compilador, através da detecção de dead branches (trechos de código que nunca seriam executados), computações repetidas de valores já conhecidos e por ser possível “juntar” certas operações em uma mesma instrução.

Para que o inlining possa acontecer, os compiladores precisam que a definição da função esteja presente na mesma translation unit (TU) em que a função é chamada. Note que, apesar dos nomes gerarem certa confusão, a presença de inline functions ou do especificador inline não é necessária para que o compilador realize o inlining.

Alguns dos fatores que contribuem para a realização ou não de inlining de uma função são:

  • A complexidade ou tamanho da função
  • O nível de otimização ustilizado durante a compilação
  • O uso de certas funções da biblioteca C

Destrinchando a necessidade do inline

Se, em última instância, quem decide sobre o inlining é o compilador, qual a necessidade do especificador inline, afinal? Para justificar sua existência, vamos analisar possíveis abordagens que possibilitam o compilador a realizar o inlining de uma função, ainda desconsiderando o uso do inline.

Considere a seguinte situação:

  • A função f é declarada no header a.h, e definida em a.c
  • A função other, declarada em other.h e definida em other.c, depende da função f
  • A função main, definida em main.c, depende das funções f e other

Código

Para efeito da discussão, consideremos que a função f é uma boa candidata para inlining, e por isso queremos que o compilador possa realizá-lo. Entretanto, o compilador está impossibilitado de fazer o inlining de f, já que sua definição é feita na TU de a.c.

Para verificar se o inlining ocorreu ou não, será analisado o assembly gerado. Especificamente, deve-se verificar a presença ou não da instrução call referente às chamadas de função no código C. E já que o assembly gerado depende das otimizações passadas ao compilador, os exemplos considerarão builds em Debug e Release. As builds em Release têm várias otimizações ligadas, enquanto as builds em Release têm poucas. Os exemplos foram montados de forma a demonstrar que esses tipos de build, que utilizam um conjunto de otimizações diferentes, interferem na realização do inlining.

Segue o assembly gerado pela build em Release.

; Release
main:
 push rbx
 call 401130 <f>
 mov ebx,eax
 call 401140 <other>
 add eax,ebx
 pop rbx
 ret 
 cs nop WORD PTR [rax+rax*1+0x0]
 nop DWORD PTR [rax+rax*1+0x0]
_dl_relocate_static_pie:
 endbr64 
 ret 
 cs nop WORD PTR [rax+rax*1+0x0]
 nop
f:
 mov eax,0x1
 ret 
 cs nop WORD PTR [rax+rax*1+0x0]
other:
 sub rsp,0x8
 call 401130 <f>
 add rsp,0x8
 add eax,0x1
 ret 
 cs nop WORD PTR [rax+rax*1+0x0]
 nop DWORD PTR [rax+rax*1+0x0]

Nota-se que:

  • As funções main, f e other tem labels correspondentes
  • other faz o call de f
  • main faz o call de f e other

A build em Debug apresenta essas mesmas características.

Apesar das otimizações na build Release, cada chamada de função no código fonte tem uma call correspondente no assembly, não havendo inlining. Conforme discutido, para que o inlining possa acontecer, a definição de f precisa estar presente nas TUs que a chamam, main.c e other.c.

A seguir, serão discutidas diferentes abordagens para resolver essa situação.

Abordagem 1 - Mover a definição de f para o header

Em vez de definir f em sua própria TU, a.c, poderíamos defini-la em a.h, e eliminar a.c completamente. Assim, todos os arquivos que dependem de f incluiriam esse header, que já possui sua definição. Veja o que acontece:

Código

O output traz a mensagem multiple definition of `f’, indicando um erro durante a execução do linker.

O problema é que a função f é definida em TUs distintas com external linkage. Isso significa que, cada uma das definições é visível para todo o programa, gerando esse conflito. O fato de que as definições têm o mesmo código (que só foi escrito uma vez, no header, afinal de contas), é irrelevante.

Fizemos com que a definição de f ficasse visível para as TUs que dependem dela, mas tivemos problemas com o linker. Para contorná-lo, vamos à Abordagem 2.

Abordagem 2 - Definir as funções nos headers com internal linkage

Se o problema anterior era ter múltiplas definições visíveis, podemos então restringir cada definição à sua TU. Assim, main.c e other.c terão suas próprias definições de f, visíveis somente para si.

Para isso, basta acrescentar a palavra-chave static à definição de f em a.h. Isso faz com que a função tenha internal linkage, ou seja, sua declaração e definição não é visível a outras TUs.

And it just works. Não temos mais múltiplas definições, o código compila e linka corretamente. Além disso, main.c e other.c têm definições de f em suas respectivas TUs, possibilitando que ocorra o inlining.

Código

Agora, vamos ao assembly. Na build em Release, as duas chamadas de f sofreram inlining. As instruções de call a f foram eliminadas, e os efeitos de sua chamada introduzidos diretamente onde a chamada da função aconteceria, eliminando o overhead da instrução call. Inclusive, a label de f nem sequer existe no assembly.

; Release
main:
 sub rsp,0x8
 call 401130 <other>
 add rsp,0x8
 add eax,0x1
 ret 
 cs nop WORD PTR [rax+rax*1+0x0]
 nop DWORD PTR [rax+rax*1+0x0]
_dl_relocate_static_pie:
 endbr64 
 ret 
 cs nop WORD PTR [rax+rax*1+0x0]
 nop
other:
 mov eax,0x2
 ret 
 cs nop WORD PTR [rax+rax*1+0x0]

Contudo, analizando o assembly da build em Debug, agora temos duas labels para f, correspondentes às definições presentes nas TUs main.c e other.c. Se houvesse mais source files, veríamos mais labels e instruções desnecessárias, fazendo com que o tamanho do executável gerado aumente.

; Debug
_dl_relocate_static_pie:
 endbr64 
 ret 
 cs nop WORD PTR [rax+rax*1+0x0]
 nop
f:
 push rbp
 mov rbp,rsp
 mov eax,0x1
 pop rbp
 ret 
main:
 push rbp
 mov rbp,rsp
 push rbx
 sub rsp,0x8
 call 401106 <f>
 mov ebx,eax
 call 401139 <other>
 add eax,ebx
 mov rbx,QWORD PTR [rbp-0x8]
 leave 
 ret 
f:
 push rbp
 mov rbp,rsp
 mov eax,0x1
 pop rbp
 ret 
other:
 push rbp
 mov rbp,rsp
 call 40112e <f>
 add eax,0x1
 pop rbp
 ret 
 nop WORD PTR [rax+rax*1+0x0]

E há mais desvantagens. Por exemplo, haverá um warning gerado sempre que um TU deixar de usar funções definidas em um header.

Abordagem 3 - Um arquivo grandão

Se o objetivo é ter a definição das funções em uma única TU, por que então não restringir o programa a uma única TU?

Por exemplo, partindo do projeto original, criar um arquivo “principal” que inclui os demais source files, e fazer dele o único arquivo a ser compilado.

//all.c
#include "main.c"
#include "a.c"
#include "other.c"

Código

Dessa forma, o compilador teria a oportunidade de realizar o inlining de todas as funções do programa, não se limitando às funções apontadas por nós, meros programadores. E vemos que isso realmente acontece: tanto f quanto other sofreram inlining. Ainda há trechos de assembly que se referem a elas, mas elas não são efetivamente chamadas durante a execução do programa. A função inicial simplesmente retorna um valor, sem call alguma.

; Release
main:
 mov eax,0x3
 ret 
 cs nop WORD PTR [rax+rax*1+0x0]
_dl_relocate_static_pie:
 endbr64 
 ret 
 cs nop WORD PTR [rax+rax*1+0x0]
 nop
f:
 mov eax,0x1
 ret 
 cs nop WORD PTR [rax+rax*1+0x0]
other:
 mov eax,0x2
 ret 
 cs nop WORD PTR [rax+rax*1+0x0]

Enquanto essa abordagem funciona para nosso exemplo, ela não funciona para um caso geral. Se diferentes arquivos introduzem um mesmo nome com internal linkage no escopo do arquivo, agora esses nomes estarão no mesmo escopo, e haverá conflito entre eles.

Mesmo que essa situação seja evitada no momento, introduzir source files adicionais ao projeto poderá se tornar inviável. Outro ponto negativo é que qualquer modificação no código fonte faz com que o programa inteiro precise ser recompilado.

Resumo

Das três abordagens apresentadas, duas não funcionam. E a que funciona tem algumas peculiaridades que gostaríamos de evitar.

  • A primeira abordagem não compila caso dois ou mais arquivos diferentes dependam de uma mesma implementação.
  • A terceira abordagem, apesar do resultado expressivo, é uma bomba relógio. Mesmo funcionando para o nosso exemplo, basta um source file que fuja dos padrões esperados por essa gambiarra e lá vem dor de cabeça.
  • A segunda abordagem funciona. Pode gerar executáveis de maior tamanho e warnings falsos positivos, mas funciona. Usando esta forma, foi verificado que o compilador pode executar o inlining.

Tá, mas e o inline?

O especificador inline possibilita que os problemas criados pela segunda abordagem sejam resolvidos. Para empregá-lo corretamente, algumas formalidades da linguagem C precisam ser discutidas. Simplesmente adicionar o inline na definição de um header pode trazer resultados inesperados. Apesar disso, veremos que a sua forma de uso mais comum é bastante simples.

Formalidades

Ao utilizar o especificador inline na declaração de uma função, ela será uma inline function, e deverá ser definida na mesma TU.

Enquanto funções “convencionais” têm apenas uma definição, as inline functions podem ter duas.

  • Quando um arquivo define uma inline function sem ter a palavra-chave extern associada à função, é dito que a definição é uma inline definition na TU em questão.
  • Opcionalmente, a inline function pode ter uma, e apenas uma, external definition, assim como as funções “convencionais”.

Quando uma inline function é chamada, é não-especificado qual das duas definições será utilizada. Esse comportamento independe da existência da definição externa existir. Caso ela seja escolhida e não exista, ocorrerá um erro durante a execução do linker.

Código

Isso quer dizer que não ter ambas as definições presentes pode fazer com que um programa só possa ser linkado em determinados níveis de otimização, como visto nesse exemplo. Em Release, vemos que houve o inlining da função f. O assembly não contém nem as calls nem uma label referente à função, idêntico àquele da Abordagem 2.

; Release
main:
 sub rsp,0x8
 call 401130 <other>
 add rsp,0x8
 add eax,0x1
 ret
 cs nop WORD PTR [rax+rax*1+0x0]
 nop DWORD PTR [rax+rax*1+0x0]
_dl_relocate_static_pie:
 endbr64
 ret
 cs nop WORD PTR [rax+rax*1+0x0]
 nop
other:
 mov eax,0x2
 ret
 cs nop WORD PTR [rax+rax*1+0x0]

Já em Debug, o linker falha com a a mensagem undefined reference to `f’, porque o compilador decidiu usar a external definition, que não existe, em vez de realizar o inlining. Troque o argumento -DCMAKE_BUILD_TYPE:STRING=Release para -DCMAKE_BUILD_TYPE:STRING=Debug para verificar que isso acontece.

Uso

Apesar dos pormenores complicados, a utilização do inline é simples.

  1. A definição da inline function vai em um header que será incluído por todos os source files que dependem da função. Isso garante que a inline definition da função esteja presente em todos as TUs que dependem da função.
  2. A external definition da função vai em um source file dedicado.

Por exemplo:

// a.h -- include guards omitidos
inline int f(void)
{
	return 1;
}
// a.c
#include "a.h"
extern inline int f(void);

A declaração extern inline int f(void); junto à inclusão do arquivo a.h é suficiente para gerar a external definition a partir do código escrito em a.h. Alternativamente, a declaração int f(void); poderia ter sido usada, tendo o mesmo efeito.

Código

O resultado é que o programa é compila e linka corretamente independente da função f sofrer inlining. Como é visto no assembly, o inlining ocorre apenas em Release.

; DEBUG
_dl_relocate_static_pie:
 endbr64 
 ret 
 cs nop WORD PTR [rax+rax*1+0x0]
 nop
main:
 push rbp
 mov rbp,rsp
 push rbx
 sub rsp,0x8
 call 401123 <f>
 mov ebx,eax
 call 40112e <other>
 add eax,ebx
 mov rbx,QWORD PTR [rbp-0x8]
 leave 
 ret 
f:
 push rbp
 mov rbp,rsp
 mov eax,0x1
 pop rbp
 ret 
other:
 push rbp
 mov rbp,rsp
 call 401123 <f>
 add eax,0x1
 pop rbp
 ret 
 nop DWORD PTR [rax+0x0]
; RELEASE
main:
 sub rsp,0x8
 call 401140 <other>
 add rsp,0x8
 add eax,0x1
 ret 
 cs nop WORD PTR [rax+rax*1+0x0]
 nop DWORD PTR [rax+rax*1+0x0]
_dl_relocate_static_pie:
 endbr64 
 ret 
 cs nop WORD PTR [rax+rax*1+0x0]
 nop
f:
 mov eax,0x1
 ret 
 cs nop WORD PTR [rax+rax*1+0x0]
other:
 mov eax,0x2
 ret 
 cs nop WORD PTR [rax+rax*1+0x0]

É notável que, apesar de não haver nenhum call a f, sua definição ainda aparece no assembly. Felizmente, aparece apenas uma vez, e corresponde à external definition de f em a.c.

Conclusão

Neste artigo, foi mostrado que compiladores podem eliminar o overhead de uma chamada de função através do inlining, quando a definição de uma função estiver presente na TU em questão.

Apesar da confusão sintática, o inlining não está diretamente ligado ao especificador inline. Esse especificador serve para possibilitar que uma função esteja definida em múltiplas TUs com o mínimo de contrapartidas.

Referências