MatAllocatorとGC

この記事はOpenCV Advent Calendar 2015の9日目の記事です。初めてこういうのに参加します。

qiita.com

目次

筆者の環境

ネタ概要

  • 私はOpenCVの.NETラッパーを作ってそろそろ8年になります。
  • ラッパーというのはGCとの闘いです。(GCがある言語なら、ですけど)
  • 一つ重大な問題として、以下のようなものがあります。MatAllocatorをうまく使えば、解決の余地があるかも?という試みを紹介します。

g2sim.rosx.net

C++/CLIによるラッパークラス

まずは、問題の現象を説明するため、cv::Matを中に持つ.NETマネージクラスを、C++/CLI言語で作ります。C++/CLIを使うのは説明・実装の簡単のためです。

ref class ManagedMat 
    : public System::IDisposable // (自動で付くので本来は冗長)
{
public:
    ManagedMat(int rows, int cols, int type)
    {
        m = new cv::Mat;
        m->create(rows, cols, type);
    }
    !ManagedMat()
    {
        if (m != nullptr)
            delete m;
        m = nullptr;
    }
    ~ManagedMat()
    {
        this->!ManagedMat();
    }
    // 関数のラッパー定義の一例
    property int Rows
    {
        int get() { return m->rows; }
    }

private:
    cv::Mat *m;
};

クラスの中で、cv::Matのポインタを保持します。ちなみにマネージクラスでは、C++のオブジェクトを値そのものでは持てず、ポインタでしか持てません。わざわざnew cv::Matしているのはそのためです。

!ManagedMat()~ManagedMat()が、C#でいうところのデストラクタとDisposeに相当し、これがあるのでスコープを抜けたらそのうちMatは解放されます。そのうち。(なおC++/CLIに限定すれば、gcnewせずにインスタンスを作れば話は別ですが、ここでは扱いません。C#などではできないため。)

以下が使用例です。これでC#, VB.NET, F#等でも、ネイティブOpenCVに触れるようになりました。

int main()
{
    auto managedMat = gcnew ManagedMat(1024, 768, CV_8UC1);
    System::Console::WriteLine(managedMat->Rows); // 1024
    return 0; // 解放される
}

メモリ不足になるシーン

では、このManagedMatのメモリ解放がちゃんとできているか、ループで呼びまくってみましょう。

for (size_t i = 0; i < 0xfffff; i++)
{
    gcnew ManagedMat(1024, 1024, CV_8UC1);
}

動作環境によりまちまちで、何事も起こらない環境もあるのですが、私の環境では数秒で以下のようなメッセージを吐いて死にます。OutOfMemory!

OpenCV Error: Insufficient memory (Failed to allocate 1048576 bytes) in cv::OutOfMemoryError, file C:\builds\master_PackSlave-win32-vc12-shared\opencv\modules\core\src\alloc.cpp, line 52
OpenCV Error: Assertion failed (u != 0) in cv::Mat::create, file C:\builds\master_PackSlave-win32-vc12-shared\opencv\modules\core\src\matrix.cpp, line 411

タスクマネージャ等でプロセスのメモリ使用量をみてみると、確かに、2GBくらいに達しています。32-bitビルドなのでこれで死ぬのは納得です。

でも、苦しくなってきたらGCが発動してくれるのでは?なぜぼーっとして死を迎えるのか?

これが今回取り上げる問題です。

メモリ使用量を見てみる

答えを先に言うと、GCがcv::Matというネイティブのメモリ使用量を感知していないためにこのようなことになります。

今度は、メモリ使用量を表示させながらループを回してみましょう。ひと口に使用量といっても、以下2つがあります。

void ShowCurrentMemoryUsage()
{
    auto p = System::Diagnostics::Process::GetCurrentProcess();
    System::Console::WriteLine("Process:{0:F2}MB / Managed:{1:F2}MB",
        p->WorkingSet64 / 1024.0 / 1024.0,
        System::GC::GetTotalMemory(false) / 1024.0 / 1024.0);
}

int main(array<System::String ^> ^args)
{
    for (size_t i = 0; i < 0xfffff; i++)
    {
        gcnew ManagedMat(1024, 1024, CV_8UC1);
        if (i % 1000 == 0)
            ShowCurrentMemoryUsage();
    }

    return 0;
}

以下が死を迎えたときのコンソール表示です。ProcessとManagedの差が歴然としていますね。.NETランタイムとしては3MBくらいしか食ってない、まだまだいける、と思っています。これはGCが働かないわけです。 f:id:Schima:20151206223454p:plain

(なお、ややこしいことに、この表示を入れるとちゃんとGCが効いて、死ななくなることがあります。)

MatAllocatorを自作する

長い前置きは終わりです。 OpenCV Advent Calendar 2015はマイナー機能を紹介せよとのお達しなので、この問題をcv::MatAllocatorにより克服してみます。

MatAllocatorについて

cv::Mat等のメモリ確保について、独自の処理を定義することができます。 デフォルトではcv::fastMallocが使われるようになっています。

念のため、リファレンスを貼っておきます。説明0で軽く絶望するくらいには、使用頻度は低いものと ご理解いただけるかと思います。 OpenCV: cv::MatAllocator Class Reference

自作してどうする?

fastMallocの代わりに.NET Frameworkのメモリ確保メソッドに置き換えてあげれば、当然.NETランタイムは感知できますし、解決になるのでは、ということです。

作ってみた

正直なところ仕様がよくわからず、cv::MatAllocator自身の設計を見よう見まねです。Pythonバインディングで使われているnumpy向けのAllocator定義も参考になります。

// !!! ここがポイントの箇所で、.NETによるメモリ確保を行い、それをGCで動かされないようにして、Matのメモリ領域として提供します。

class ManagedAllocator : public cv::MatAllocator
{
public:
    virtual cv::UMatData* allocate(int dims, const int* sizes, int type,
        void* data0, size_t* step, int flags, cv::UMatUsageFlags usageFlags) const override
    {
        using namespace System;
        using namespace System::Runtime::InteropServices;

        CV_Assert(dims == 2); // TODO

        // この辺はMatAllocatorと同じ
        size_t total = CV_ELEM_SIZE(type);
        for (int i = dims - 1; i >= 0; i--)
        {
            if (step)
            {
                if (data0 && step[i] != CV_AUTOSTEP)
                {
                    CV_Assert(total <= step[i]);
                    total = step[i];
                }
                else
                    step[i] = total;
            }
            total *= sizes[i];
        }

        auto *u = new cv::UMatData(this);

        GCHandle handle;
        uchar* data = (uchar*)data0;
        if (data0)
        {
            u->flags |= cv::UMatData::USER_ALLOCATED;
        }
        else
        {
            // !!! ここがポイント
            auto managedBuffer = gcnew array<uchar>(total);
            handle = GCHandle::Alloc(managedBuffer, GCHandleType::Pinned);
            data = reinterpret_cast<uchar*>(handle.AddrOfPinnedObject().ToPointer());
        }

        u->data = u->origdata = data;
        u->size = total;
        u->userdata = GCHandle::ToIntPtr(handle).ToPointer(); // 解放時に使うため、GCHandleを持たせる

        return u;
    }

    // よくわかってないのでMatAllocator丸パクり
    bool allocate(cv::UMatData* u, int accessFlags, cv::UMatUsageFlags usageFlags) const override
    {
        if (!u)
            return false;
        return true;
    }

    void deallocate(cv::UMatData* u) const override
    {
        using namespace System;
        using namespace System::Runtime::InteropServices;

        if (!u)
            return;

        CV_Assert(u->urefcount >= 0);
        CV_Assert(u->refcount >= 0);
        if (u->refcount == 0)
        {
            if (!(u->flags & cv::UMatData::USER_ALLOCATED))
            {
                u->origdata = 0;
                u->data = 0;

                // マネージ配列(byte[])のピンを外し、GC対象に入れる
                GCHandle handle = GCHandle::FromIntPtr(IntPtr(u->userdata));
                if (handle.IsAllocated)
                {
                    handle.Free();
                }
            }
            delete u;
        }
    }
};

static ManagedAllocator g_myAllocator;

ManagedAllocatorをcv::Matに使わせる

最初に作ったManagedMatクラスを以下のように書き替えます。

ref class ManagedMat 
{
public:
    ManagedMat(int rows, int cols, int type, bool useMyAllocator) 
    {
        m = new cv::Mat;
        if (useMyAllocator) // マネージ確保を使うかどうか
            m->allocator = &g_myAllocator;  // createの前に設定
        m->create(rows, cols, type);
    }
    /* 中略 */
};

試す

useMyAllocatorをtrueにして、動かしてみましょう。

for (size_t i = 0; i < 0xfffff; i++)
{
    gcnew ManagedMat(1024, 1024, CV_8UC1, true);
    if (i % 1000 == 0)
        ShowCurrentMemoryUsage();
}

今度は動きました。ProcessとManagedが逆転しています。これはこれで興味深いですね。 f:id:Schima:20151207002124p:plain

おわりに

以上から、MatAllocatorの自作によりGCを働かせることができました。 しかしながら、色々怪しい部分が多く、まだ本採用できそうにありません。

  • 3次元以上の配列に適用できているのか怪しい
  • その他実装が胡散臭い(フラグとか全部無視ですし)
  • そのせいか、1分ぐらいループを回し続けると落ちる・・・

また、毎度Matの作成の度にallocatorを手作業でセットしています。これをデフォルトにしてしまう方法がよくわかりませんでした。仮に手で設定しかできないということは、例えばOpenCV内部処理のMat確保等は手を出せないということです。すべてのメモリを.NETランタイムで握ることは難しそうです。

まだまだ至らぬ点が多いままのおしまいです。

(参考)別の方法

以上までの方法とは全く別の方法を紹介しておきます。

GC.AddMemoryPressure GC.RemoveMemoryPressure を使う方法です。

AddMemoryPressureにより、.NETランタイムのあずかり知らぬところで何バイトのメモリが確保されたのか、報告することができます。以下はこれを自作Allocatorに適用してみた例です。

#pragma once

#include <opencv2/opencv.hpp>

class ManagedAllocator : public cv::MatAllocator
{
public:
    virtual cv::UMatData* allocate(int dims, const int* sizes, int type,
        void* data0, size_t* step, int flags, cv::UMatUsageFlags usageFlags) const override
    {
        // 中略

        System::GC::AddMemoryPressure((Int64)total);

        size_t *totalData = new size_t(total);
        u->userdata = (void*)totalData;

        // 中略
    }

    void deallocate(cv::UMatData* u) const override
    {
        if (!u)
            return;

        CV_Assert(u->urefcount >= 0);
        CV_Assert(u->refcount >= 0);
        if (u->refcount == 0)
        {
            if (!(u->flags & cv::UMatData::USER_ALLOCATED))
            {
                // 中略

                size_t *totalData = (size_t*)(u->userdata);
                System::GC::RemoveMemoryPressure((Int64)(*totalData));
                delete totalData;
            }
            delete u;
        }
    }
};

メモリ確保を自分で書くという危ない橋を渡らずに済みますし、こちらの方が望ましく思えます。事実、以前のOpenCvSharpではこれに相当する処理を入れていました。

しかし現在は取りやめました。それは、GC.Add(Remove)MemoryPressureはスレッドのロックがかかるらしいためです。

この現象は、会社のシステムで使っていて、並列処理数が増えるにつれて急激に性能劣化が起きたことで気づきました。計算途中で作られる細かなMat1つ1つでロックがかかるのですから、そう思えば納得の状況ですが、何も知らない当時は全く奇怪な状態で大変疲弊しました。

おそらく大半の利用者にとっては、そこまで多重の並列処理をすることは少なそうですし、かえって単列のキャプチャ処理などのMatが膨大に生まれる処理にてメモリ溢れの防止が無くなってしまい、困るかもしれません。ただしそれはDispose/usingを書けば回避はできますから、回避不能なAddMemoryPressureのロック排除を優先しました。

ビジネスで使うと、思いもよらないことに出くわすことが多いなあ・・・という感想でした。