Em um post anterior, questionei o porquê do depurador, no código indicado na figura que segue, em Release, não estar respeitando um breakpoint.
IMPORTANTE: O modo release não é indicado para depuração. Em modo Release, o compilador implementa otimizações (eliminação de código inútil, inlining, etc..) que impedem, em muitos cenários, a correlação entre o código que está rodando e o código que escrevemos.
A resposta simples e direta para o “problema”, no meu caso, foi uma otimização implementada pelo JIT chamada inlining.
IMPORTANTE: O comportamento que observei pode variar em diferentes ambientes operacionais, configurações no Visual Studio e versões do .NET.
O que é Inlining?
Trazendo a resposta da Wikipedia:
In computing, inline expansion, or inlining, is a manual or compiler optimization that replaces a function call site with the body of the called function. […]
Inlining is an important optimization, but has complicated effects on performance.[1] As a rule of thumb, some inlining will improve speed at very minor cost of space, but excess inlining will hurt speed, due to inlined code consuming too much of the instruction cache, and also cost significant space.
Em .NET, o JIT aplica inlining em determinados cenários com vistas a melhorar a performance, quando o código assembly é gerado. Ou seja, não ocorre quando o IL é gerado, mas somente no tempo de execução.
Relação de Inlining com o “problema”
Em minha ambiente, o JIT havia optado por fazer inlining de um dos meus métodos, mas não de outro. Daí o motivo de não haver a parada em um dos métodos – o “método” não existia no assembly.
Como “recomendar” ou negar inlining ao JIT
Em .NET, podemos “sugerir” ao JIT sobre fazer inlining, ou não, de nosso código.
using System; using System.Runtime.CompilerServices; public class Program { public static void Main() { string s = "Elemar Jr"; PrintAllChars(s); PrintAllChars_(s); } [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void PrintAllChars(string s) { foreach (var c in s) { Console.WriteLine(c); } } [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void PrintAllChars_(string s) { for (int i = 0; i < s.Length; i++) { Console.WriteLine(s[i]); } } }
A utilização do atributo MethodImpl com o parâmetro AggressiveInlining sugere fortemente ao compilador que ele deve implementar Inlining. Por outro lado, o parâmetro NoInlining garante que o JIT não execute a otimização.
Geralmente, não é necessário que influenciemos o JIT nesse aspecto. Entretanto, em cenários críticos, indicar inlining pode ter impacto crítico na performance.
Medindo impacto de inlining na performance
O código que segue mostra uma comparação entre métodos onde ocorre inlining e onde não ocorre.
using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; using System.Runtime.CompilerServices; public class Program { static void Main() { BenchmarkRunner.Run<Program>(); } [Benchmark] public int Inlining() { return WithInlining(2) + WithInlining(3); } [Benchmark] public int Calc_Mixed() { return WithInlining(2) + WithoutInling(3); } [Benchmark] public int NoInlining() { return WithoutInling(2) + WithoutInling(3); } [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int WithInlining(int value) { return value; } [MethodImpl(MethodImplOptions.NoInlining)] private static int WithoutInling(int value) { return value; } }
Resultados:
No exemplo, o método que usou apenas métodos forçando Inlining foi muito mais rápido. Isso pode ser facilmente entendido olhando o assembly gerado para ele:
mov eax, 5 ret
Não há chamada para método algum. Além disso, o JIT também é inteligente o suficiente para eliminar a adição. Afinal, são duas contantes.
O método que chama um método com inlining e outro sem inlining tem alguma penalidade de performance.
sub rsp,28h mov ecx,3 call 00007FFCF2F310C0 add eax,2 add rsp,28h ret
Repare que o método é mais lento porque precisa criar um registro na stack, executar a chamada, e restaurar a stack. Além disso, agora, a remoção da operação de adição não foi possível.
Por fim, o método que chama dois métodos sem fazer inlining é o mais lento.
sub esp,20h mov ecx,2 call 00007FFCF2F010C0 mov esi,eax mov ecx,3 call 00007FFCF2F010C0 add eax,esi add rsp,20h pop rsi ret
O motivo, agora, é evidente. Há o overhead de duas chamadas e, no final, a execução da operação de adição.
IMPORTANTE: Tentar influenciar na decisão do compilador sobre fazer, ou não, inlining é um tipo de “micro-otimização”. Exceto em cenários extremos, você não deveria se preocupar com isso.
Concluindo
O JIT consegue implementar, automaticamente, otimizações fantásticas em nossos códigos. Inlining é apenas uma delas.
Boa parte das otimizações implementadas pelo JIT não deveriam ser “preocupação” para o desenvolvedor, exceto em cenários extremos.
Depurar em Release não é coerente. Se, mesmo assim, você resolver fazer isso, entenda que comportamentos “estranhos” do Visual Studio não indicam “problemas” no ambiente.
Dúvidas? Sugestões? Compartilhe suas impressões nos comentários.
Tens como você fazer um post explicando uma boa prática para disponibilizarmos os PDBs em release, talvez usando um Symbol Server. Isso visando análise futura de DUMPs ou mesmo debug/profiler em produção.
Parabéns pelos ótimos POSTs Elemar.