天狗会議録 Posts Pages About

DLL・SOの暗黙的/動的リンクの速度比較

#experiment2023/3/30

背景

ゲーム制作向けフロントエンドフレームワークのAPIをどうしようかと考えたところ、どっちかを動的リンクライブラリにするのが良いだろうという結論に至った。

動的リンクには、暗黙的なリンクと動的的なリンクの二つの手法がある。 事前に用いるライブラリ名(とその検索パス)を指定しておく方法と、dlsym関数やGetProcAddress関数を用いて動的に関数を取得する方法とである。

なんとなく、関数を余計に呼び出す分、速度が落ちるのではないかと思い、速度比較実験を行ってみた。 ただし、リンクする関数の数が少ない場合は、僅差過ぎて測れないだろうので、考慮しないものとする。

Linux

今回用いたソースファイルは以下。

  • sosrcgen.rs:共有ライブラリのソースコード生成器
  • imsrcgen.rs:暗黙的リンク用のソースコード生成器
  • exsrcgen.rs:動的リンク用のソースコード生成器
  • Makefile:ビルド用

コードは順に以下のよう。簡単に言えば、100000個の外部関数を動的にリンクするプログラムである。

// sosrcgen.rs
use std::io::Write;
fn main() {
    let mut out = std::io::BufWriter::new(std::io::stdout().lock());
    for i in 0..100000 {
        write!(out, "int f{0}() {{ return {0}; }} ", i).unwrap();
    }
}
// imsrcgen.rs
use std::io::Write;
const MAIN1: &'static str = "
#include <stdio.h>
int main() {
    int sum = 0;
";
const MAIN2: &'static str = "
    printf(\"sum = %d\\n\", sum);
    return 0;
}
";
fn main() {
    let mut out = std::io::BufWriter::new(std::io::stdout().lock());
    for i in 0..100000 {
        write!(out, "int f{}(); ", i).unwrap();
    }
    write!(out, "{}", MAIN1).unwrap();
    for i in 0..100000 {
        write!(out, "sum += f{}(); ", i).unwrap();
    }
    write!(out, "{}", MAIN2).unwrap();
}
// exsrcgen.rs
use std::io::Write;
const MAIN1: &'static str = "
#include <stdio.h>
#include <dlfcn.h>
int (*fs[100000])();
int main() {
    int sum = 0;
    void *handle = dlopen(\"./libtestso.so\", RTLD_LAZY);
";
const MAIN2: &'static str = "
    printf(\"sum = %d\\n\", sum);
    return 0;
}
";
fn main() {
    let mut out = std::io::BufWriter::new(std::io::stdout().lock());
    write!(out, "{}", MAIN1).unwrap();
    for i in 0..100000 {
        write!(out, "fs[{0}] = (int (*)())dlsym(handle, \"f{0}\"); ", i).unwrap();
    }
    for i in 0..100000 {
        write!(out, "sum += (*fs[{}])(); ", i).unwrap();
    }
    write!(out, "{}", MAIN2).unwrap();
}
.PHONY: all
all: libtestso.so imtest extest
libtestso.so: sosrcgen.rs
    rustc sosrcgen.rs
    ./sosrcgen > sosrc.c
    gcc -shared -fPIC -o libtestso.so sosrc.c
imtest: imsrcgen.rs
    rustc imsrcgen.rs
    ./imsrcgen > imsrc.c
    gcc -o imtest imsrc.c libtestso.so -Wl,-rpath,'$ORIGIN/'
extest: exsrcgen.rs
    rustc exsrcgen.rs
    ./exsrcgen > exsrc.c
    gcc -o extest exsrc.c
clean:
    rm -f \
        libtestso.so sosrc.c sosrcgen \
        imtest imsrc.c imsrcgen \
        extest exsrc.c exsrcgen

雑にtimeコマンドで計測した結果、実行時間は両者とも15ミリ秒程度で、目立った差はなかった。 どころか、動的リンクの方がファイルサイズが小さく、かつ実行時間が僅差で短かった。

本来ならば、やたら複雑な関数をリンクするならば遅くなるのかとか、ばらばらのタイミングでリンクすると遅くなるのかとか、検証すべきケースは考えられるが、これほど速ければ特に変わりないのではと思える。

Windows

ソースファイルは以下。 imtest.exeのビルド(恐らくリンク)にとんでもない時間が掛かって途中でプロセスが吹き飛んだため、Linuxと違って、20000個の関数をリンクすることにした。 また、QueryPerformanceCounterでリンクと計算にかかった時間を計測した。

  • dllsrcgen.rs:DLLのソースコード生成器
  • imsrcgen.rs:暗黙的リンク用のソースコード生成器
  • exsrcgen.rs:動的リンク用のソースコード生成器
  • Makefile:ビルド用
// dllsrcgen.rs
use std::io::Write;
fn main() {
    let mut out = std::io::BufWriter::new(std::io::stdout().lock());
    for i in 0..20000 {
        write!(out, "int __stdcall f{0}() {{ return {0}; }} ", i).unwrap();
}
}
// imsrcgen.rs
use std::io::Write;
const MAIN1: &'static str = "
#include <stdio.h>
#include <windows.h>
int main() {
    int sum = 0;
    LARGE_INTEGER freq;
    LARGE_INTEGER before;
    LARGE_INTEGER after;
    QueryPerformanceFrequency(&freq);
    QueryPerformanceCounter(&before);
";
const MAIN2: &'static str = "
    QueryPerformanceCounter(&after);
    printf(\"sum = %d, time = %lf\\n\", sum, (after.QuadPart - before.QuadPart) * 1000.0 / freq.QuadPart);
    return 0;
}
";
fn main() {
    let mut out = std::io::BufWriter::new(std::io::stdout().lock());
    for i in 0..20000 {
        write!(out, "int f{}(); ", i).unwrap();
    }
    write!(out, "{}", MAIN1).unwrap();
    for i in 0..20000 {
        write!(out, "sum += f{}(); ", i).unwrap();
    }
    write!(out, "{}", MAIN2).unwrap();
}
// exsrcgen.rs
use std::io::Write;
const MAIN1: &'static str = "
#include <stdio.h>
#include <windows.h>
int (*fs[20000])();
int main() {
    int sum = 0;
    LARGE_INTEGER freq;
    LARGE_INTEGER before;
    LARGE_INTEGER after;
    QueryPerformanceFrequency(&freq);
    QueryPerformanceCounter(&before);
    HMODULE module = LoadLibraryA(\"testdll.dll\");
";
const MAIN2: &'static str = "
    QueryPerformanceCounter(&after);
    printf(\"sum = %d, time = %lf\\n\", sum, (after.QuadPart - before.QuadPart) * 1000.0 / freq.QuadPart);
    FreeLibrary(module);
    return 0;
}
";
fn main() {
    let mut out = std::io::BufWriter::new(std::io::stdout().lock());
    write!(out, "{}", MAIN1).unwrap();
    for i in 0..20000 {
        write!(out, "fs[{0}] = (int (*)())GetProcAddress(module, \"f{0}\"); ", i).unwrap();
    }
    for i in 0..20000 {
        write!(out, "sum += (*fs[{}])(); ", i).unwrap();
    }
    write!(out, "{}", MAIN2).unwrap();
}
.PHONY: all
all: testdll.dll imtest.exe extest.exe
testdll.dll: dllsrcgen.rs
    rustc dllsrcgen.rs
    dllsrcgen > dllsrc.c
    gcc -shared -o testdll.dll dllsrc.c
imtest.exe: imsrcgen.rs
    rustc imsrcgen.rs
    imsrcgen > imsrc.c
    gcc -o imtest.exe imsrc.c testdll.dll
extest.exe: exsrcgen.rs
    rustc exsrcgen.rs
    exsrcgen > exsrc.c
    gcc -o extest.exe exsrc.c
clean:
    del /F \
        testdll.dll dllsrc.c dllsrcgen.pdb dllsrcgen.exe \
        imtest.exe imsrc.c imsrcgen.pdb imsrcgen.exe \
        extest.exe exsrc.c exsrcgen.pdb exsrcgen.exe

計測結果は以下のよう。ただし、小数点第二位四捨五入している。

Static Link [ms]Dynamic Link [ms]
10.684.75
20.504.89
30.494.15
40.434.84
50.504.89
Ave0.524.70

こちらは動的リンクが遅くなった。また、実行バイナリキャッシュがないときのReal時間も明らかに動的リンクの方が遅い。 矢張り暗黙的リンクの方が実行ファイルのサイズが大きい上に、ビルド時間が滅茶苦茶長くなるので、何か施しているのかもしれない。

「objdump -d」で逆アセンブルしてぼんやりと見ても、Import Name Tableの有無の違いがあるだけで、どちらも愚直に計算している。 for文で関数ポインタ配列を回して見かけ上の命令数を少なくしてみたが、速度改善はせず。

どうやらWindowsでは、動的リンクは遅いらしい。

結論

Linuxでは動的リンク、Windowsでは暗黙的リンクを用いるのが合理的である。 が、Linuxでは大した差がなかったので、どちらも暗黙的リンクを用いるのが良いだろう。