xin9le.net

Microsoft の製品/技術が大好きな Microsoft MVP な管理人の技術ブログです。

FastEnum の初期化コストと利用指針

先日、FastEnum に関して非常に良い質問を受けました。短期間だけ起動してすぐにアプリ/インスタンスが死んでいくバッチ処理などにおいては、逆に初期化コストが大きくなってしまうのでないか?というものです。

確かに実行速度はメチャクチャ速いのですが、初期化コストがどの程度かは把握していませんでした。というのも、FastEnum は Web サービスやクライアントアプリなどの比較的長く利用されるものに対する応答速度を極限まで向上させることを目指しているので、初期化コストは warm up などで無視するものとして割り切っていました。ですが、確かに初期化コストを無視できないようなシチュエーションでの利用にどの程度耐えられるのかは気になります。

計測結果

ということで計測してみました。結果は以下のような感じです。呼び出し回数は、FastEnum の初期化の間に System.Enum の各種メソッドを何度呼び出せるのかを示しています。

処理 時間 [ns] 呼び出し回数 アロケーション [B]
FastEnum.Init 22,075.16 1.00 8,067
Enum.GetValues 1,337.97 16.50 352
Enum.GetNames 45.57 484.42 128
Enum.GetName 58.82 375.30 24
Enum.IsDefined 119.85 184.19 24
Enum.TryParse 139.91 157.78 24
Enum.ToString 33.95 650.23 24

f:id:xin9le:20191030000118p:plain

どうやら、一度ひとつの列挙型を初期化するのは 17 回ほど Enum.GetValues() を呼び出すのと同等なようです。Enum.ToString() であれば 650 回ほど。こう見ると結構遅い...気がする!初期化するときの実装には言うほど気を配っていないのが明るみに出た感じががが...。

と言っても、これらの数字は Web サービスのリクエスト数などからすればすぐにペイできます。初期化コストを気にするよりも一度のリクエストを高速に捌く方がずっと大切なので、言うほどではないでしょう。逆に短命なアプリやバッチ処理の場合は利用しない方が良いでしょう。ケースバイケースで使い分けるのが良いかと思います。

けど、やっぱりもうちょっと初期化コストを抑えるように頑張っても良いかもしれない...

ASP.NET Core 3.0 で cshtml の Edit & Continue を有効化する

ASP.NET Core でフロントエンド開発をするときは「デバッグ実行しながら cshtml の編集をしてブラウザを Reload!」というのが王道中の王道なのではないかと思います。所謂 cshtml に対する Edit & Continue です。少なくとも僕は今までずっとそうしてきました。Web Essentials というヤツの中に BrowserSync なるものがありますが、僕はそういう類の拡張機能は入れたくない派なので使ってないのです。

という、基本中の基本である開発中の行動が ASP.NET Core 3.0 になったら突然できなくなって大変お困り!どうやら ASP.NET Core 3.0 で仕様変更があり、実行時コンパイルを廃止したみたいです。一度コンパイルしたものをずっと使うのはパフォーマンス上とても良いのでありがたい話ですし、ASP.NET Core を Roslyn に依存しない形にするという点では正しい分離な気はします。が、そうは言っても開発中はすこぶる困る!

Edit & Continue を有効にする

Razor の実行時コンパイルの機能は、ASP.NET Core 3.0 から Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation というライブラリとして分離されています。これを NuGet からインストールしましょう。インストールできたら Startup.cs.AddRazorRuntimeCompilation() を追加します。たったこれだけです :)

services
    .AddMvc()
    .AddRazorRuntimeCompilation();  // 追加

この機能は基本的に開発環境だけに適用されれば良いはずなので、以下のようにするのも効果的かと思います。

var mvcBuilder = services.AddMvc();    
if (this.Environment.IsDevelopment())
    mvcBuilder.AddRazorRuntimeCompilation();

DeclarativeSql rebooted - Now supports .NET Standard 2.0

数年間開発を放置した DeclarativeSql を更新して .NET Standard 2.0 に対応させました!過去バージョンとの互換は結構崩れてしまっている点は大変申し訳ないのですが、今の自分が欲しいなぁという機能をシンプルに詰め込むために致し方なし。

実は .NET Core がまだリリース前だった頃から .NET Core 対応を目論んでいて net-core というブランチでヒッソリと作業していたのですが、.NET Standard 1.0 が出たての頃と言ったら IDbConnection を廃止するだの Type / TypeInfo あたりが壊滅的に使いにくいなど超絶グダグダで、それに辟易して結局諦めてしまいました。だけど時代が進んで今は .NET Standard 2.x。フレームワーク起因で開発上困るようなことがほとんどなくなったのと、改めて .NET Core に対応したものが欲しくなったのでメンテしました。

GitHub の ReadMe に書いてあることのコピペみたいなものですが、僕は日本語が最も得意 () なので日本語でもある程度解説をば。

属性ベースのテーブルマッピング

Entity Framework などはそうですが、テーブルとマップする型に属性をアレコレ付けることで SQL をイイ感じに生成できるようにします。これが最も面倒な作業ですが、データベース内のテーブル表現をコード上で (ある程度) 再現しないと自動化できないので、こればっかりは頑張って書くしかないです。Entity Framework はテーブル情報を引っ張ってきてコードを自動生成してくれるので強いんですが、そういう自動化するコードを書くのはそこまで難しくないので、各々のプロジェクトでイイ感じに自動生成するバッチでも作ればよいと思っています。

using System;
using DeclarativeSql.Annotations;

namespace SampleApp
{
    // DB の種類ごとにテーブル名をカスタマイズできる
    [Table(DbKind.MySql, "T_Customer")]
    [Table(DbKind.SqlServer, "T_Person", Schema = "dbo")]
    public class Person
    {
        [PrimaryKey]  // 主キー
        [AutoIncrement]  // 自動採番
        public int Id { get; set; }

        [Unique(0)]  // インデックスで一意制約を指定
        public string Email { get; set; }

        // DB の種類ごとに列名も変えられる
        [Column(DbKind.MySql, "氏名")]
        [Column(DbKind.SqlServer, "名前")]
        public string Name { get; set; }

        [AllowNull]  // Nullable
        public int? Age { get; set; }

        [CreatedAt]  // 生成日時を入れるマーカー属性
        [DefaultValue(DbKind.SqlServer, "SYSDATETIME()")]  // 入れる値
        public DateTimeOffset CreatedOn { get; set; }

        [ModifiedAt]  // 更新日時を入れるマーカー属性
        [DefaultValue(DbKind.SqlServer, "SYSDATETIME()")]  // 入れる値
        public DateTimeOffset UpdatedOn { get; set; }
    }
}

特に今回最も頑張った機能が [CreatedAt] / [ModifiedAt] 属性 + [DefaultValue] 属性による挿入/更新日時の自動設定です。毎回毎回 DateTimeOffset.Now をプロパティに入れて Insert / Update するの、相当ダルくないですか?僕はとてもダルい!そして DateTimeOffset.Now は複数台のサーバーになったときにサーバーによって時間が違うので、時刻の信頼性が若干低下します。データベースの時刻を使えばより誤差を低減できると期待できます。ので、そういう信頼性高めの日時の挿入を自動でやってれる SQL を吐き出したかった...という私欲まみれなヤツです。

他にも以前から「SQL Server と MySQL で列名とかスキーマ名が違うんだけど同じものを表すテーブルを表現したい」みたいな要望がありました。複数の DB に同時に接続することを中途半端にしか (ぇ) 想定していなかったので、今回はそういうところにももうちょっと配慮して [Table] 属性と [Column] 属性を作ってみたりしました。ちゃんと応えられているのかは若干不安ですが...。

SQL の自動生成

テーブルとマッピングする型が手に入れば SQL を自動生成できます。SQL は方言が多くてなんでもかんでも対応するのは無理なので、基本どんなデータベースに対しても動くものだけカジュアルサポートしています。作れる SQL は以下の通りです。それぞれドストレートなメソッドが提供されているので簡単に分かると思います。

  • count
  • select
  • insert
  • update
  • delete
  • truncate
  • where
  • order by
var sql
    = DbProvider.SqlServer.QueryBuilder
    .Select<Person>(x => new { x.Id, x.Name })
    .Where(x => x.Name == "xin9le")
    .OrderByDescending(x => x.Name)
    .ThenBy(x => x.CreatedOn)
    .Build()
    .Statement;

/*
select
    [Id] as Id,
    [名前] as Name
from [dbo].[T_Person]
where
    [名前] = @p1
order by
    [名前] desc,
    [CreatedOn]
*/
var sql
    = DbProvider.SqlServer.QueryBuilder
    .Insert<Person>()
    .Build()
    .Statement;

/*
insert into [dbo].[T_Person]
(
    [Email],
    [名前],
    [Age],
    [CreatedOn],
    [UpdatedOn]
)
values
(
    @Email,
    @Name,
    @Age,
    SYSDATETIME(),
    SYSDATETIME()
)
*/
var sql
    = DbProvider.SqlServer.QueryBuilder
    .Update<Person>(x => new { x.Name, x.Age })
    .Where(x => x.Age < 35 || x.Name == "xin9le")
    .Build()
    .Statement;

/*
update [dbo].[T_Person]
set
    [名前] = @Name,
    [Age] = @Age,
    [UpdatedOn] = SYSDATETIME()
where
    [Age] < @p1 or [名前] = @p2
*/

Dapper との統合

データベースを相手にしていて最も使うのは超シンプルな CRUD です。inner join とか group by みたいな複雑な (?) ものこそ SQL を手書きすればよし!という割り切りのもと、シンプルな CRUD に対するショートハンドなメソッドを提供します。これは過去のバージョンから変わってない機能です。下記のサンプルは同期メソッドで書いてありますが、非同期メソッド版もありますのでご安心を。

//--- 全件取得
var p1 = connection.Select<Person>();

//--- 特定列だけに絞りつつ全件取得
var p2 = connection.Select<Person>(x => new { x.Id, x.Name });

//--- 'ID = 3' のレコードのみ取得
var p3 = connection.Select<Person>(x => x.Id == 3);

//--- 'ID = 3' のレコードを特定列に絞って取得
var p4 = connection.Select<Person>
(
    x => x.Id == 3,
    x => new { x.Id, x.Name }
);
//--- 1 件 insert
var p5 = connection.Insert(new Person { Name = "xin9le", Age = 30 });

//--- 複数件 Insert
var p6 = connection.InsertMulti(new []
{
    new Person { Name = "yoshiki", Age= 49, },
    new Person { Name = "suzuki",  Age= 30, },
    new Person { Name = "anders",  Age= 54, },
});

//--- バルク処理で insert
//--- 現在 SQL Server のみ対応 : そのうち他もやるかも
var p7 = connection.BulkInsert(new []
{
    new Person { Id = 1, Name = "yoshiki", Age= 49, },
    new Person { Id = 2, Name = "suzuki",  Age= 30, },
    new Person { Id = 3, Name = "anders",  Age= 54, },
});

//--- insert 後に自動採番された ID を取得
var p8 = connection.InsertAndGetId(new Person { Name = "xin9le", Age = 30 });
//--- 条件に一致する行の指定列を更新
var p9 = connection.Update
(
    new Person { Name = "test", Age = 23 },
    x => x.Age == 30,
    x => new { x.Name, x.Age }
);
//--- 全件削除
var p10 = connection.Delete<Person>();

//--- 条件に一致する行を削除
var p11 = connection.Delete<Person>(x => x.Age != 30);
//--- truncate 文による全件削除
var p12 = connection.Truncate<Person>();
//--- テーブルのレコード数を取得
var p13 = connection.Count<Person>();

//--- 条件に一致するレコード数を取得
var p14 = connection.Count<Person>(x => x.Name == "xin9le");

高可用性を持つデータベース接続

サービスの規模が相当大きくなってくるとデータベースへの負荷が上がってレスポンスが悪化したりします。そういうときは DB Server のインスタンスサイズを上げて金で解決するのが最も簡単な手法ですが、稀にそれで追いつかなくなって「読み込みは Read Replica からにしたい」みたいなケースも出てくるかと思います。そう言ったときに、先手を打って Master / Slave が別になることを想定したコードを書いておくと良いでしょう。

Master / Slave がインフラによって同期されていることが前提ですが、HighAvailabilityConnection はその一助になるのではないかと思います。

public class FooConnection : HighAvailabilityConnection
{
    public FooConnection()
        : base("ConnectionString-ToMasterServer", "ConnectionString-ToSlaveServer")
    {}

    protected override IDbConnection CreateConnection(string connectionString, AvailabilityTarget target)
        => new SqlConnection(connectionString);
}
using (var connection = new FooConnection())
{
    //--- Slave DB から読み込み
    var p = connection.Slave.Select<Person>();

    //--- Master DB に書き込み
    connection.Master.Insert(new Person { Name = "xin9le" });
}

Master / Slave の同期がどのくらいの期間で行われるかはインフラ/クラウド環境次第なので、自身のサービスで担保したい高可用性のレベルにマッチする場合にお使いください。

まとめ

ちょっとは戦える...と思う。難しいクエリをしたいときは生 SQL で良いけど、簡単な SQL まで手書きするのはイヤ!という怠惰な自分のための一品です。そして僕は Entity Framework と戦いたいわけでは決してないのです。

FastEnum - 更なる高速化を果たした v1.1.0 リリース

先の記事FastEnum という超高速な Enum Utility ライブラリを作ってリリースしてから 3 日。v1.1.0 をリリースしました。パフォーマンスはさらに磨きがかかっていて、他の追随を許さないレベルに昇華されました。最早グラフにしても棒が表示されてない...(

f:id:xin9le:20190909221347p:plain

今回は主に以下のような変更を行っています。早速思いっきり Breaking Chages!!

  • API Surface が大きく変更に
  • 更なるパフォーマンスの改善
  • API の追加
  • etc...

API Surface の変更

のいえ先生 (@neuecc) から「Generics Class な API は使い心地が悪いからヤだ」と指摘を受け、全くそのとーりだったので改善しました。思いっきり Breaking Changes!! なんですが、リリース数日なので許してください!(泣

概ね以下のように変更されています。Generics クラスだったものを Generics メソッドに変更した感じです。大変自然というか、System.Enum にかなり近づいた API になったと思います。

//--- Before
var values = FastEnum<Fruits>.Values;
var names = FastEnum<Fruits>.Names;
var defined = FastEnum<Fruits>.IsDefined(123);
var parse = FastEnum<Fruits>.Parse("Apple");
var tryParse = FastEnum<Fruits>.TryParse("Apple", out var value);

//--- After
var values = FastEnum.GetValues<Fruits>();
var names = FastEnum.GetNames<Fruits>();
var defined = FastEnum.IsDefined<Fruits>(123);
var parse = FastEnum.Parse<Fruits>("Apple");
var tryParse = FastEnum.TryParse<Fruits>("Apple", out var value);

更なるパフォーマンスの改善

.NET Core に勝っただけで世界最速!などと大きなことを言ってしまったことを反省しています。世の中には Enums.NET というライブラリがあって、同様に high-performance を謳っていました。それとの比較なしに最速を勝手に語るのは思い上がりなので、ちゃんと比較しました。

結果として IsDefined メソッドで Enums.NET より 4 倍ほど遅い結果が出てしまって大変がっかり...。

ということで必死に改善しました。改善した結果は記事冒頭のグラフの通りで、Enums.NET と比べて 6 倍 (v1.0 の実装と比べると 25 倍) 程速くなっています。どのメソッドにおいても Enums.NET よりも System.Enum よりも速い!今度こそ最速!(たぶん

まとめ

1 [ns] レベルの改善するために数日かけるという非効率の極み...。このチューニングによる時間は回収できるものなのか分かりませんが、大変勉強にはなりました。もうこれ以上の速度は出せない気がする (気がするだけかも) ので、ぜひ速いものに巻かれる (?) つもりで利用してみてください。バグ報告や PR などもお待ちしております :)

FastEnum - 世界最速の enum ライブラリ

というのを、この数日をかけて勢いで作りました。ずーーっと昔から「enum は遅い」と言われ続けていたので何か手を入れたいと思っていたのですが、突然やる気になりました。久々にプライベートのコーディング意欲が爆上げしたー。勢いが強過ぎて毎日寝不足のまま明け方までのコーディングを繰り返し、しかも家庭を顧みませんでした...(超ヨクナイ

どのくらい速いかというと、.NET Core 3.0 よりも何倍も速いです。もう狂ったくらい速い。全てのメソッドがゼロ・アロケーションです。プログラミングする上で enum はかなり使うので、多少なり性能改善に寄与できるのではないかと思います。ぜひ使ってみた感想をいただければなーと思います :)

f:id:xin9le:20190906201828p:plain

簡単に使い方解説

.NET 標準の System.Enum に近い使い方ができるように API を設計しています。以下のような感じで、とてもシンプル!

//--- FastEnum
var values = FastEnum<Fruits>.Values;
var names = FastEnum<Fruits>.Names;
var name = Fruits.Apple.ToName();
var defined = FastEnum<Fruits>.IsDefined(123);
var parse = FastEnum<Fruits>.Parse("Apple");
var tryParse = FastEnum<Fruits>.TryParse("Apple", out var value);
//--- .NET
var values = Enum.GetValues(typeof(Fruits)) as Fruits[];
var names = Enum.GetNames(typeof(Fruits));
var name = Enum.GetName(typeof(Fruits), Fruits.Apple);
var defined = Enum.IsDefined(typeof(Fruits), 123);
var parse = Enum.Parse<Fruits>("Apple");
var tryParse = Enum.TryParse<Fruits>("Apple", out var value);

おまけ機能

enum 周りでよく使う機能があって、それらも一緒に組み込むことでより便利に使えるようにしています。

1. メンバー情報を丸ッと取得

たまに enum の値と名前のペアが欲しくなることがあります。そういうケースに耐えるために Member<TEnum> というのを用意しています。FieldInfo なども一緒に入っているので、リフレクションなどのお供にご利用ください。

class Member<TEnum>
{
    public TEnum Value { get; }
    public string Name { get; }
    public FieldInfo FieldInfo { get; }
    // etc...
}

//--- こんな感じで取得できるので、煮るなり焼くなり
var member = Fruits.Apple.ToMember();

2. EnumMemberAttribute.Value の取得

EnumMemberAttribute をフィールド名の別名として利用している方をちょくちょく見かけます。このケースに対応するため、Value プロパティからサッと値を取得できるようにしました。

enum Company
{
    [EnumMember(Value = "Apple, Inc.")]
    Apple = 0,
}

var value = Company.Apple.GetEnumMemberValue();  // Apple, Inc.

3. 複数のラベルを付与

EnumMemberAttributeAllowMultiple = false になっているため、同一のフィールドに対して複数の属性を付けられません。それが不便で個人的には好んでおらず、その代替として LabelAttribute というのを作って使っています。FastEnum にこの機能を追加することで、以下のような感じで便利に利用できます。

enum Company
{
    [Label("Apple, Inc.")]
    [Label("AAPL", 1)]
    Apple = 0,
}

var x1 = Company.Apple.GetLabel();   // Apple, Inc.
var x2 = Company.Apple.GetLabel(1);  // AAPL

制限

System.Enum に対して完全に互換があるかというと、実はそうではないです。標準で提供されているいくつかの機能を削ることで速さを得ている部分があります。

1. Generics な API のみを提供

標準の System.Enumtypeof(TEnum) を用いたオーバーロードが用意されているのですが、その場合、引数か戻り値が object になります。そこで box 化が走って非常に遅くなってしまうので、機能として削ることで速度を稼ぐようにしています。

2. カンマ区切りの文字列を Parse できない

実は System.Enum.Parse は以下のような感じの文字も parse できます。あまり知られていないんじゃないかと思うくらい、ひっそりと存在する仕様です。

//--- こんなのがあるとして
[Flags]
enum Fruits
{
    Apple = 1,
    Lemon = 2,
    Melon = 4,
    Banana = 8,
}

//--- カンマ区切りの文字列を与える
var value = Enum.Parse<Fruits>("Apple, Melon");
Console.WriteLine((int)value);  // 5

フラグ処理をするときに便利な機能のようですが、これまで 10 年以上一度も使ったことがありませんでした。このカンマ区切りの解析を加えようとするとそのオーバーヘッドが出てしまうので、思い切って機能を削ることで速度を稼いでいます。ほとんど使われない (少なくとも自分は使わない) 機能のために速度を落とすのが嫌だったのでそうしています。そのあたりは本家に譲ればいいかな、と...。

速さの秘訣

言わずもがなでお察しかもしれませんが、内部でキャッシュしているからです。Static Type Caching というアプローチを取っていて、そのおかげで読み込み速度が限りなく 0 になっています*1。これを基礎としつつ、アロケーション回避のためのテクニックを交えたり、内部で利用する辞書を特定のキーに特化させるなどしています*2

このあたりのテクニックはのいえ先生 (@neuecc) の講演スライドCysharp 印の GitHub リポジトリが大変参考になります。

*1:BenchmarkDotNet にも「計測不可能」と言われる速度

*2:保守性の観点では好ましくないけれど、速度を稼ぐためにやむを得ず...