Kinesis Firehoseを使ってLambda(C#)からS3にファイル出力する

今回はKinesis Firehoseを使って、Lambdaから受け取ったレコードをS3に出力させてみます。

Kinesis Firehoseの作成

Kinesis Firehoseは受信したレコードをプログラミングゼロでS3/Redshift/Elasticsearchへ流し込むことができるストリームサービスで、現在は米国リージョンでのみの提供されています。

Amazon Kinesis Firehose(ストリーミングデータを AWS へ簡単にロード) | AWS

今回は、S3(delivery-s3バケット)に出力するKinesis Firehose(delivery-stream)を作成します。 - 60秒間隔(または5KB単位)でS3へ出力され、それまではFirehose内でバッファリングされます - GZIP形式で出力します

f:id:ohke:20170331085712j:plain:w400

Lambdaの実装

次に作成したFirehoseへレコードをPUTするLambdaを実装します。引数をJSON文字列にして、そのままFirehoseへPUTしています。 - Amazon.KinesisFirehoseを使うため、project.jsonではdependenciesにAWSSDK.KinesisFirehoseを追加してください

using System.IO;
using System.Net;
using System.Text;
using Amazon;
using Amazon.KinesisFirehose;
using Amazon.KinesisFirehose.Model;
using Amazon.Lambda.Core;
using Newtonsoft.Json;

namespace AWSLambda
{
    public class KinesisFirehosePutFunction
    {
        private AmazonKinesisFirehoseClient _firehoseClient = new AmazonKinesisFirehoseClient(RegionEndpoint.USWest2);
        private string _firehoseName = "delivery-stream";

        [LambdaSerializer(typeof(Amazon.Lambda.Serialization.Json.JsonSerializer))]
        public async System.Threading.Tasks.Task<string> KinesisFirehosePutHandler(KinesisFirehoseHandlerInput input, ILambdaContext context)
        {
            var data = JsonConvert.SerializeObject(input);

            // Amazon.KinesisFirehose.Model.RecordインスタンスのDataプロパティにPUTするデータを詰める
            var record = new Record()
            {
                Data = new MemoryStream(Encoding.UTF8.GetBytes(data))
            };

            // Kinesis Firehose(ストリーム名:delivery-stream)へ作成したRecordインスタンスをPUTする
            var response = await _firehoseClient.PutRecordAsync(_firehoseName, record);

            if (response.HttpStatusCode == HttpStatusCode.OK)
            {
                return $"RecordId: {response.RecordId}";
            }
            else
            {
                return "Error";
            }
        }
    }

    public class KinesisFirehoseHandlerInput
    {
        public string StringValue { get; set; }
        public int IntValue { get; set; }
        public double DoubleValue { get; set; }
    }
}

Lambdaを実行するロールには、firehose:PutRecordの許可権限を付与します。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "firehose:PutRecord"
      ],
      "Resource": "arn:aws:firehose:us-west-2:XXXXXXXXXXXX:deliverystream/delivery-stream"
    }
  ]
}

Lambdaに適当な引数を与えて実行すると、指定したバケットのyyyy/mm/dd/hhフォルダ配下にストリーム名-バージョン-タイムスタンプ-GUID.gzのファイルが作成されます。

f:id:ohke:20170331090148j:plain

{"StringValue":"text1","IntValue":10,"DoubleValue":2.5}

また、インターバルを1分で指定しており、例えば数秒間で2レコードを受信した場合は、以下のように1ファイルへマージされてS3へ出力されます。

{"StringValue":"text2","IntValue":10,"DoubleValue":2.5}{"StringValue":"text3","IntValue":10,"DoubleValue":2.5}

C# Regexクラスのインスタンスメソッドと静的メソッドの性能比較

大量のURL文字列を正規表現パターンとマッチングして分類するバッチ処理C#で開発していたところ、実装が進んで分類を増やすと、唐突に割に合わない処理時間となってしまったことがありました。 原因としては正規表現のマッチング方法が非効率的だったためでした。

今回はRegexクラスの静的メソッドを使ったマッチングとインスタンスメソッドを使ったマッチングで性能比較してみます。

C#での正規表現

C#正規表現ではRegexクラス(System.Text.RegularExpressions)が一般的に使われますが、マッチングにおいてはいくつかのアプローチが提供されています。

特にインスタンス化の方法はパフォーマンスに大きな影響を与えることがあり、MSDNでも言及されています。 https://msdn.microsoft.com/ja-jp/library/gg578045(v=vs.110).aspx

マッチング方法は主に2つあります。

Regex静的メソッドを使う

例えば、文字列inputで4桁の数字をマッチングする場合はこんな感じです。

using System.Text.RegularExpressions;

Regex.Match(input, "[0-9]{4}");

メソッドの呼び出しによって正規表現パターン("[0-9]{4}“)がオペレーションコードへ変換されるのですが、この変換結果はキャッシュされるようになっています。 キャッシュされるパターン数はRegex.CacheSizeプロパティで指定できます(デフォルトで15)。

冒頭の事象は、分類が増えたことによってキャッシュする変換済みの正規表現パターンが増え、キャッシュミスが頻発したことによってパフォーマンスが劣化したためでした。

Regexインスタンスメソッドを使う

Regexクラスを正規表現パターンを引数としてインスタンス化する方法もあります。

using System.Text.RegularExpressions;

var regex = new Regex("[0-9]{4}");
regex.Match(input);

インスタンス化することで正規表現パターンから変換されたオペレーションコードをキャッシュにかかわらず持たせることができます。 冒頭の事象では、分類が限られていたということもあり、インスタンス化することで解決しました。

さらにオペレーションコードからMSILまでコンパイルするオプション(RegexOptions.Compiled)も用意されており、繰り返しマッチングする場合はコンパイルしたほうが高速とのことです。

var regex = new Regex("[0-9]{4}", RegexOptions.Compiled);

パフォーマンス比較

手軽なのは静的メソッドを使う方法ですが、何度も同じ正規表現文字列を使う場合にはインスタンスメソッドを使うことがパフォーマンスの面で推奨されています。
1,000,000件のGuid文字列から"[0-9]{4}“をマッチングするコードを4パターンで実装し、実行時間を比較してみました。

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text.RegularExpressions;

namespace ConsoleApplication
{
    public class Program
    {
        public static void Main(string[] args)
        {
            long staticMethodTotal = 0;
            long staticMethodNoCacheTotal = 0;
            long instanceMethodTotal = 0;
            long instanceMethodCompiledTotal = 0;

            Console.WriteLine(Regex.CacheSize);

            // 10回平均を求める
            for (var try_num = 0; try_num < 10; try_num++) 
            {
                // 1,000,000個のGUID文字列からなるリストを生成
                var list = new List<string>();
                for (int i = 0; i < 1000000; i++)
                {
                    list.Add(new Guid().ToString());
                }

                var sw = new Stopwatch();

                // パターン1. Regex静的メソッドを使う(キャッシュ有り)
                Regex.CacheSize = 1;
                sw.Start();
                UseStaticMethod(list);
                sw.Stop();
                staticMethodTotal += sw.ElapsedMilliseconds;

                // パターン2. Regex静的メソッドを使う(キャッシュ無し)
                Regex.CacheSize = 0; // キャッシュ数を0にして無効化
                sw.Reset();
                sw.Start();
                UseStaticMethod(list);
                sw.Stop();
                staticMethodNoCacheTotal += sw.ElapsedMilliseconds;

                // パターン3. Regexインスタンスメソッドを使う(コンパイルオプション無効)
                sw.Reset();
                sw.Start();
                UseInstanceMethod(list);
                sw.Stop();
                instanceMethodTotal += sw.ElapsedMilliseconds;

                // パターン4. Regexインスタンスメソッドを使う(コンパイルオプション有効)
                sw.Reset();
                sw.Start();
                UseInstanceMethod(list, true);
                sw.Stop();
                instanceMethodCompiledTotal += sw.ElapsedMilliseconds;
            }

            Console.WriteLine(staticMethodTotal / 10.0);
            Console.WriteLine(staticMethodNoCacheTotal / 10.0);
            Console.WriteLine(instanceMethodTotal / 10.0);
            Console.WriteLine(instanceMethodCompiledTotal / 10.0);
        }

        private static List<bool> UseStaticMethod(List<string> list)
        {
            var result = new List<bool>();

            foreach (var guid in list)
            {
                result.Add(Regex.IsMatch(guid, "[0-9]{4}"));
            }

            return result;
        }

        private static List<bool> UseInstanceMethod(List<string> list, bool compiled = false)
        {
            var regex = compiled ? 
                        new Regex("[0-9]{4}", RegexOptions.Compiled) : 
                        new Regex("[0-9]{4}");

            var result = new List<bool>();

            foreach (var guid in list)
            {
                result.Add(regex.IsMatch(guid));
            }

            return result;
        }
    }
}

結果は結果は以下の通りでした。

  • キャッシュの有無(パターン1.と2.の比較)は強力で、約7倍の性能差となって現れました
    • 暗黙的にキャッシュされるので、少し正規表現パターンを増やしただけなのに性能がガクッと落ちた際はキャッシュミスを疑ってみるのはありです
  • インスタンスメソッドの方が高速(パターン1.と3.の比較)になりましたが、感覚的にはそこまで大きな差がない印象です
    • 純粋にインスタンスの生成コストの差のようで、キャッシュさえ効いていればどっちでも良いですね
  • コンパイル有無(パターン3.と4.の比較)については定説と逆転してしまいました(謎です。。。)
実行時間[ms]
パターン1. Regex静的メソッドを使う(キャッシュ有り) 472
パターン2. Regex静的メソッドを使う(キャッシュ無し) 3329
パターン3. Regexインスタンスメソッドを使う(コンパイルオプション無効) 258
パターン4. Regexインスタンスメソッドを使う(コンパイルオプション有効) 266

今回は単純な正規表現パターンで比較しましたが、バックトラッキングなどを使ってもっと複雑になってくると、より顕著に性能差が広がると思われます。

C#: Dictionaryのキーに複数の値を使う

Dictionaryのキーに複数の値を使う方法について、考察してみます。

Dictionaryのキーに複数の値を使う方法

Dictionaryのキーに複数の変数値を使いたい場合(例えば、クラス名と出席番号をキーとして氏名を値とする、など)にいくつかの方法が考えられますが、以下3つに分類されるかと思います。

  1. キーの値を連結するなどして1つのキーを作成する
var className = "Aクラス";
var number = 1;
var name = "荒岩 一味";

var students = new Dictionary<string, string>();
students.Add(className + number, name);
Console.WriteLine(students[className + number]);
  1. Dictionaryを値とするDictionaryを作成する(Dictionary<TKey1, Dictionary<TKey2, TValue>>)
var students = new Dictionary<string, Dictionary<int, string>>();
students.Add(className, new Dictionary<int, string>{ { number, name } });
Console.WriteLine(students[className][number]);
  1. Tupleをキーとする(Dictionary<Tuple<TKey1, TKey2>, TValue>)
var students = new Dictionary<Tuple<string, int>, string>();
students.Add(new Tuple<string, int>(className, number), name);
Console.WriteLine(students[new Tuple<string, int>(className, number)]);

1.は気軽に実装できるのですが、キーの型が異なる場合に型変換を行う必要があったり、結合した結果キーが重複する場合("クラス1"と"23"、"クラス12"と"3"は結合するといずれも"クラス123"となる)を考慮しないといけないなど、使えるケースは限定的です。

実際の開発ではキーの型と同一性が保証される2.か3.を採用することが多いかと思います。

で、どっちを使うかですが、実装レベルではキーを1つにまとめられるTupleを使う3.の方法の方が素直だと思います。 2.の方法では、値(TValue)を追加・参照するたびに、上位のDictionaryのキー(TKey1)の有無を常にチェックする必要があるのも面倒です。

性能は状況により異なりそうですが、2.の方が分は良さそうです。

  • 追加・参照のたびにTupleを生成する必要がある
  • TKey1が値の数に比べて少ない場合は、メモリ効率が良い(TKey1が集約されるため)

性能比較

それぞれの方法でDictionaryに値を追加・参照するコードを使って、両者の性能を比較してみます。

firstKeyPatternは上位のDictionaryのキー(TKey1)、secondKeyPatternは下位のDictionaryのキー(TKey2)のパターン数です。 例えば、firstKeyPatternが2、secondKeyPatternが3の場合、キーは("0", 0)、("0", 1)、("0", 2)、("1", 0)、("1", 1)、("1", 2)の6パターンとなります。

using System;
using System.Collections.Generic;
using System.Diagnostics;

namespace ConsoleApplication
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var firstKeyPattern = 1;
            var secondKeyPattern = 1000000;

            var sw = new Stopwatch();
            var name = "荒岩 一味";

            sw.Start();
            var firstDictionary = new Dictionary<string, Dictionary<int, string>>();
            for (int i = 0; i < firstKeyPattern; i++)
            {
                var secondDictionary = new Dictionary<int, string>();
                for (int j = 0; j < secondKeyPattern; j++)
                {
                    secondDictionary.Add(j, name);
                }
                firstDictionary.Add(i.ToString(), secondDictionary);
            }
            sw.Stop();
            Console.WriteLine("2.(Add): " + sw.ElapsedMilliseconds);
            sw.Reset();

            sw.Start();
            var list = new List<string>();
            for (int i = 0; i < firstKeyPattern; i++)
            {
                if (firstDictionary.ContainsKey(i.ToString()))
                {
                    for (int j = 0; j < secondKeyPattern; j++)
                    {
                        if (firstDictionary[i.ToString()].ContainsKey(j))
                        {
                            list.Add(firstDictionary[i.ToString()][j]);
                        }
                    }
                }
            }
            sw.Stop();
            Console.WriteLine("2.(Get): " + sw.ElapsedMilliseconds);
            sw.Reset();

            sw.Start();
            var tupleDictionary = new Dictionary<Tuple<string, int>, string>();
            for (int i = 0; i < firstKeyPattern; i++)
            {
                for (int j = 0; j < secondKeyPattern; j++)
                {
                    tupleDictionary.Add(new Tuple<string, int>(i.ToString(), j), name);
                }
            }
            sw.Stop();
            Console.WriteLine("3.(Add): " + sw.ElapsedMilliseconds);
            sw.Reset();

            sw.Start();
            list = new List<string>();
            for (int i = 0; i < firstKeyPattern; i++)
            {
                for (int j = 0; j < secondKeyPattern; j++)
                {
                    var key = new Tuple<string, int>(i.ToString(), j);
                    if (tupleDictionary.ContainsKey(key))
                    {
                        list.Add(tupleDictionary[key]);
                    }
                }
            }
            sw.Stop();
            Console.WriteLine("3.(Get): " + sw.ElapsedMilliseconds);
        }
    }
}

Macbook Pro(.net core 1.1)の環境で実行した結果は以下の通りでした。

  • TKey1のパターンが少ない場合は、2.の方法が性能的には優位
  • 最悪ケース(firstKeyPattern=1000000, secondKeyPattern=1)でも両者の性能差はそれほど無い
    • Tupleの生成や比較にかかるコストが高そう
実行時間(ms) 2.値の追加 2.値の参照 3.値の追加 3.値の参照
firstKeyPattern=1, secondKeyPattern=1000000 60 359 600 781
firstKeyPattern=1000, secondKeyPattern=1000 124 345 691 726
firstKeyPattern=1000000, secondKeyPattern=1 978 741 853 937

まとめ

複数の変数を組み合わせてDictionaryのキーとする方法をそれぞれ比較してみました。

実装の素直さを優先したり格納するデータが少ない場合はDictionary<Tuple<TKey1,TKey2>, TValue>、データ数が多く一方のキーに重複が多い(カーディナリティが低い)場合はDictionary<TKey1, Dictionary<TKey2, TValue>>のデータ構造を使う、というのが妥当な線ですね。