前書き
この記事は、2023のUnityアドカレの12/14の記事です。
今年は、完走賞に挑戦してみたいと思います。Qiita君ぬい欲しい!
はじめに
プログラミングにおける必須機能として、「関数呼び出し」というものがあるのは皆さまご存知でしょう。しかし、関数呼び出しにはコストがかかります。
なので、コンパイラによって関数の「呼び出し」を関数の中身に置き換えてしまう最適化を「インライン展開」といいます。
.NET Frameworkや.NET(CoreCLR)では、メソッドに[MethodImpl(MethodImplOptions.AggressiveInlining)]
をつけることで、インライン展開されやすくなるらしいです。つけたからといって必ずインライン展開されるとは限らず、何らかの条件付きのようです。(条件についてははっきりしたことが分かっていないので、ソースも読んでみたいですね)
メモ
INLINE_OBSERVATION
enum class InlineObservation
{
#define INLINE_OBSERVATION(name, type, description, impact, scope) scope##_##name,
#include "inline.def"
#undef INLINE_OBSERVATION
};
https://github.com/dotnet/runtime/blob/486142a/src/coreclr/jit/inline.h#L165
INLINE_OBSERVATION(IS_FORCE_INLINE, bool, "aggressive inline attribute", INFORMATION, CALLEE)
https://github.com/dotnet/runtime/blob/486142a/src/coreclr/jit/inline.def#L96
CALLEE_IS_FORCE_INLINE
//------------------------------------------------------------------------
// NoteAttempt: do bookkeeping for an inline attempt
//
// Arguments:
// result -- InlineResult for successful inline candidate
void InlineStrategy::NoteAttempt(InlineResult* result)
{
InlineObservation obs = result->GetObservation();
if (obs == InlineObservation::CALLEE_BELOW_ALWAYS_INLINE_SIZE)
m_AlwaysCandidateCount++;
else if (obs == InlineObservation::CALLEE_IS_FORCE_INLINE)
m_ForceCandidateCount++;
else
m_DiscretionaryCandidateCount++;
}
https://github.com/dotnet/runtime/blob/486142a/src/coreclr/jit/inline.cpp#L954-L971
//------------------------------------------------------------------------
// NoteBool: handle a boolean observation with non-fatal impact
//
// Arguments:
// obs - the current obsevation
// value - the value of the observation
void DefaultPolicy::NoteBool(InlineObservation obs, bool value)
{
// Check the impact
InlineImpact impact = InlGetImpact(obs);
// Handle most information here
bool isInformation = (impact == InlineImpact::INFORMATION);
bool propagate = !isInformation;
if (isInformation)
{
switch (obs)
{
case InlineObservation::CALLEE_IS_FORCE_INLINE:
// We may make the force-inline observation more than
// once. All observations should agree.
assert(!m_IsForceInlineKnown || (m_IsForceInline == value));
m_IsForceInline = value;
m_IsForceInlineKnown = true;
break;
...
default:
// Ignore the remainder for now
break;
}
}
if (propagate)
NoteInternal(obs);
}
https://github.com/dotnet/runtime/blob/486142a/src/coreclr/jit/inlinepolicy.cpp#L266-L289
で、我らがUnityでは、.NETのJITとは全く異なるランタイム、IL2CPPの上で生きています。IL2CPPはC#(からコンパイルされたIL)を完全にC++に変換してプラットフォームのネイティブとしてコンパイルさせます。巷ではIL2CPPでもAggressiveInlining
は有効だといわれており、Unity公式のソースにも片っ端からつけられています。
今回は、それが実際どのようなC++に変換されているのかとともに、他のMethodImplOptionについても見ていきたいと思います。
C#コード
MethodImplOptionsを網羅的に書いてみました。属性をOn/Offして差分を見ていきましょう。
public class il2cpp_attribute : MonoBehaviour
{
[MethodImpl(MethodImplOptions.InternalCall)]
public static extern void InternalCall();
[MethodImpl(MethodImplOptions.Unmanaged)]
public static extern void Unmanaged();
[MethodImpl(MethodImplOptions.PreserveSig)]
public static extern void PreserveSig();
[MethodImpl(MethodImplOptions.ForwardRef)]
public static extern void ForwardRef();
[MethodImpl(MethodImplOptions.NoInlining)]
public static void NoInlining() {}
[MethodImpl(MethodImplOptions.Synchronized)]
public static void Synchronized() {}
[MethodImpl(MethodImplOptions.NoOptimization)]
public static void NoOptimization() {}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void AggressiveInlining() {}
}
public static class il2cpp_attribute_caller
{
public static void CallInternalCall() => il2cpp_attribute.InternalCall();
public static void CallUnmanaged() => il2cpp_attribute.Unmanaged();
public static void CallPreserveSig() => il2cpp_attribute.PreserveSig();
public static void CallForwardRef() => il2cpp_attribute.ForwardRef();
public static void CallNoInlining() => il2cpp_attribute.NoInlining();
public static void CallSynchronized() => il2cpp_attribute.Synchronized();
public static void CallNoOptimization() => il2cpp_attribute.NoOptimization();
public static void CallAggressiveInlining() => il2cpp_attribute.AggressiveInlining();
}
InternalCall
InternalCallをつけると、メソッドの中身はこのようなコードが追加されます。(名前につくハッシュは省略しています)
IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR void il2cpp_attribute_InternalCall_m (const RuntimeMethod* method)
{
typedef void (*il2cpp_attribute_InternalCall_m_ftn) ();
static il2cpp_attribute_InternalCall_m_ftn _il2cpp_icall_func;
if (!_il2cpp_icall_func)
_il2cpp_icall_func
= (il2cpp_attribute_InternalCall_m_ftn)il2cpp_codegen_resolve_icall ("il2cpp_attribute::InternalCall()");
_il2cpp_icall_func();
}
メソッド名をキーとして、il2cpp_codegen_resolve_icall
を呼び出し、ランタイム側に実装された関数のテーブルを漁り、取得して呼び出します。
Il2CppMethodPointer il2cpp_codegen_resolve_icall(const char* name)
{
Il2CppMethodPointer method = il2cpp::vm::InternalCalls::Resolve(name);
if (!method)
il2cpp::vm::Exception::Raise(il2cpp::vm::Exception::GetMissingMethodException(name));
return method;
}
Il2CppMethodPointer InternalCalls::Resolve(const char* name)
{
// Try to find the whole name first, then search using just type::method
// if parameters were passed
// ex: First, System.Foo::Bar(System.Int32)
// Then, System.Foo::Bar
ICallMap::iterator res = s_InternalCalls.find(name);
if (res != s_InternalCalls.end())
return res->second;
std::string shortName(name);
size_t index = shortName.find('(');
if (index != std::string::npos)
{
shortName = shortName.substr(0, index);
res = s_InternalCalls.find(shortName);
if (res != s_InternalCalls.end())
return res->second;
}
return NULL;
}
Unmanaged, PreserveSig, ForwardRef
Unmanagedは、C++/CLIのUnmanaged関数に、ForwardRefはIL SupportによってILで書いた関数にBindingするものです。PreserveSigはちょっとよくわからなかったのですが…(COM関連っぽい…?🤔)
これらは、IL2CPPには関係ないものなので、何も変化しませんでした。
IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR void il2cpp_attribute_Unmanaged_m (const RuntimeMethod* method)
{
IL2CPP_RAISE_MANAGED_EXCEPTION(
il2cpp_codegen_get_missing_method_exception(
"The method 'System.Void il2cpp_attribute::Unmanaged()' has no implementation."),
NULL);
}
IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR void il2cpp_attribute_PreserveSig_m (const RuntimeMethod* method)
{
IL2CPP_RAISE_MANAGED_EXCEPTION(
il2cpp_codegen_get_missing_method_exception(
"The method 'System.Void il2cpp_attribute::PreserveSig()' has no implementation."),
NULL);
}
IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR void il2cpp_attribute_ForwardRef_m (const RuntimeMethod* method)
{
IL2CPP_RAISE_MANAGED_EXCEPTION(
il2cpp_codegen_get_missing_method_exception(
"The method 'System.Void il2cpp_attribute::ForwardRef()' has no implementation."),
NULL);
}
Synchronized
これは、そのメソッドの呼び出しにロックをかけるオプションです。つまり下のようなコードと同じふるまいをします。
SynchronizedMethod();
// ↓ 自動で
lock(hoge){ SynchronizedMethod(); }
これは上記のように何らかでロックを取った形にトランスパイルされる…かと思いきや、スルーされていました。
IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR void il2cpp_attribute_Synchronized_m (const RuntimeMethod* method)
{
return;
}
このオプションは、BCLでもTextWriter.Synchronized
などで使われているので、スレッドセーフだと思っていたメソッドが実はスレッドセーフじゃないということもあり得るので注意してください。
ちなみに、ConcurrentQueue
などのコンカレントコレクションは、MethodImplOptionではなく、lockで実装されているので安心して使えます。
NoOptimization
IL2CPP_DISABLE_OPTIMIZATIONS
が付きました。
IL2CPP_DISABLE_OPTIMIZATIONS
IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR void il2cpp_attribute_NoOptimization_m (const RuntimeMethod* method)
{
return;
}
プラットフォームによっては、__pragma(optimize("", off))
や__attribute__ ((optnone))
に展開されるようです。
#ifdef _MSC_VER
#define IL2CPP_DISABLE_OPTIMIZATIONS __pragma(optimize("", off))
#define IL2CPP_ENABLE_OPTIMIZATIONS __pragma(optimize("", on))
#elif IL2CPP_TARGET_LINUX || IL2CPP_TARGET_QNX
#define IL2CPP_DISABLE_OPTIMIZATIONS
#define IL2CPP_ENABLE_OPTIMIZATIONS
#else
#define IL2CPP_DISABLE_OPTIMIZATIONS __attribute__ ((optnone))
#define IL2CPP_ENABLE_OPTIMIZATIONS
#endif
NoInlining
IL2CPP_NO_INLINE
という修飾が付きました。
IL2CPP_EXTERN_C IL2CPP_NO_INLINE IL2CPP_METHOD_ATTR void il2cpp_attribute_NoInlining_m (const RuntimeMethod* method)
{
return;
}
IL2CPP_NO_INLINE
は__declspec(noinline)
や__attribute__ ((noinline))
に展開されるようです。
#if IL2CPP_COMPILER_MSVC || defined(__ARMCC_VERSION)
#define IL2CPP_NO_INLINE __declspec(noinline)
#else
#define IL2CPP_NO_INLINE __attribute__ ((noinline))
#endif
AggressiveInlining
本命、AggressiveInliningです。これは2つの変化が見られました。
まず1つ目に、IL2CPP_ENABLE_OPTIMIZATIONS
が付きました。これはNoOptimizationと対です。
IL2CPP_ENABLE_OPTIMIZATIONS
IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR void il2cpp_attribute_AggressiveInlining_m (const RuntimeMethod* method)
{
return;
}
2つ目に、呼び出し側のcppファイルにコピーが生成されました。また、こちら側にはIL2CPP_MANAGED_FORCE_INLINE
がつけられています。
IL2CPP_MANAGED_FORCE_INLINE
IL2CPP_METHOD_ATTR void il2cpp_attribute_AggressiveInlining_m_inline (const RuntimeMethod* method)
{
return;
}
IL2CPP_MANAGED_FORCE_INLINE
は、lnline
、__attribute__ ((always_inline))
、 に展開されるようです。__forceinline
#if defined(__GNUC__) || defined(__SNC__) || defined(__clang__)
#if defined(__GNUC__) && IL2CPP_GCC_VERSION < 100100
#define IL2CPP_FORCE_INLINE inline
#else
#define IL2CPP_FORCE_INLINE inline __attribute__ ((always_inline))
#endif
#define IL2CPP_MANAGED_FORCE_INLINE IL2CPP_FORCE_INLINE
#elif defined(_MSC_VER)
#define IL2CPP_FORCE_INLINE __forceinline
#define IL2CPP_MANAGED_FORCE_INLINE inline
#else
#define IL2CPP_FORCE_INLINE inline
#define IL2CPP_MANAGED_FORCE_INLINE IL2CPP_FORCE_INLINE
#endif
しかし、inline系の属性がどうのというよりも、呼び出し側のcppファイルにコピーされていることに注目すべきです。C++では翻訳単位という概念が存在します。基本的には、includeなどのプリプロセッサを全展開した後のファイルが翻訳単位になります。
// それぞれの翻訳単位でコンパイルしオブジェクトファイルに
il2cpp_attribute.cpp → il2cpp_attribute.o
il2cpp_attribute_caller.cpp → il2cpp_attribute_caller.o
// オブジェクトファイルをリンク
il2cpp_attribute.o + il2cpp_attribute_caller.o
故に、関数の実装が別の翻訳単位に存在すると、オブジェクトファイルへのコンパイル時に中身がわからないのでインライン展開できません。そのため、C++では、インライン展開されてほしい関数は基本的にヘッダーに実装を書きます。
// myutil.h
inline void Functuin()
{
printf("Hello World!");
}
// myutil.cpp
/* 実装無し */
// main.cpp
#include "myutil.h"
void main()
{
Function();
}
比較用に普通に書くとこんな感じです。
// myutil.h
extern void Functuin();
// myutil.cpp
#include "myutil.h"
void Functuin()
{
printf("Hello World!");
}
// main.cpp
#include "myutil.h"
void main()
{
Function();
}
ところで、C++では、翻訳単位内に実装が複数個所あるとコンパイルエラーになってしまいます。なので、以下のように、インクルードしたいヘッダに別のヘッダがインクルードされていて…みたいなことがあると簡単に壊れてしまいます。
// myutil2.h
#include "myutil.h"
extern void Function2();
// main.cpp
#include "myutil.h"
#include "myutil2.h" /* 二重実装のコンパイルエラー */
void main()
{
Function();
Function2();
}
inline属性をつけると、実装が複数あっても、実装内容が同じであればコンパイルが通ります。
// myutil.h
inline void Functuin()
{
printf("Hello World!");
}
// myutil2.h
#include "myutil.h"
inline void Functuin2() {}
// main.cpp
#include "myutil.h"
#include "myutil2.h"
void main()
{
Function();
Function2();
}
(私は面倒なので、main以外はいつも全部ヘッダにinlineで書いちゃってますw)
まとめ
まとめると、AggressiveInliningをつけると、inlineをつけるだけじゃなくて、インライン展開条件も満たされやすいコードをはいてくれますよということです。(再帰とかはさすがにどうにもなりませんが)
ちっちゃいコードにはバンバンAggressiveInliningをつけていきましょう!🔥
ライセンス表記
dotnet/runtime
The MIT License (MIT)
Copyright (c) .NET Foundation and Contributors
All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.