Write and Run

it's a simple way, but the only way.

Rustで古典的なDisk-Oriented DBMSを実装した話

KOBA789 です。みなさん DBMS は好きですか。私は好きです。

最近、自作 DBMS をずっと作っていて、ようやく最低限の機能ができたので公開をしました。 (とはいえコードを書いていたのは正味2日ほど。設計と勉強に2週間かかった)

github.com

この記事ではこれを作った目的と、そのちょっとした詳細についてご紹介します。

目的

Disk-Oriented DBMS の学習に適している Rust で書かれた実装が欲しかった、というのが理由です。

DBMS の勉強に適している実装というのは意外と多くありません。
MySQLPostgreSQL といった有名な実装は実用的である一方でコード量は非常に多く、また細かな最適化によって教科書的なアルゴリズムと実際のコードの差が大きくなっているため、初学者にとっては構造を把握しづらくなっています。
教科書的な実装の Disk-Oriented DBMS というのは実装がクソめんどくさい割にそのままでは性能が悪く非実用的なためにわざわざ実装する人はおらず、現代では勉強に適した典型的な Disk-Oriented DBMS の実装はほとんどありません。

また Disk-Oriented DBMS は複数のスレッドが1つのバッファを奪い合うという設計上、当然ながら latching のテクニックが重要になります。
C 言語で実装する場合には教科書に載っているアルゴリズム擬似コードをそのまま実装すればよいのですが、これを Rust で実装するとどうなるのかに興味がありました。
Rust で Disk-Oriented DBMS を実装しようだなんて思う人間はまずいないため、やはり自分で実装するしかありませんでした。

勉強と設計

Rust 特有の難しさはあとでなんとかすることにしても、まずは Disk-Oriented DBMS の実装テクニックを知る必要があります。

とっかかりとしては CMU のこの講義が参考になりました。

www.youtube.com

この講義では BusTub という C++ で書かれた Disk-Oriented DBMS を課題の題材に使っており、この実装も参考になります。

github.com

課題で学生に実装を書かせるために重要な部分はだいたいスケルトンになっており、詳細を知るには課題のヒントを見ながら自力で実装しなければなりません。 スケルトンになっているとはいえ、クラス設計はほぼ完成しているのでそこは大変参考になります。

このあたりで一度、バッファプールとテーブルヒープの実装を書き上げ、フルスキャンしかできない DBMS を作りました。
しかしフルスキャンしかできず、トランザクションもない DBMS というのは大変つまらないものです。
ここで次に B+Tree を実装するかトランザクションを実装するか悩んだのですが、フルスキャンしかできない DBMS で 2PL を実装するとすべてがテーブルロックになることに気づいたので、先に B+Tree を実装することにしました。
Predicate Lock を実装すればインデックスがなくても細かいロックで 2PL を実装できるとか、2PL ではなく PostgreSQL 的な Append-Only MVCC なら(Serializable にはならないけど)できるんじゃないかとかあったんですが、それはそれです。

B+Tree の実装については上記の講義動画に加え、Modern B-Tree Techniques という本が非常に参考になりました。

now publishers - Modern B-Tree Techniques

200ページに渡ってほぼ B-Tree についてのみ書かれているのですが、その分非常に丁寧でだいたい全部書いてあるのでおすすめです。
困ってもこれを読めば解決するという安心感があります。

このあたりで固定長のページをノードとした B+Tree に、可変長のデータを入れようとすると途端に実装が面倒になることに気が付きます。
データ構造の解説などで登場する B-Tree もほぼすべてメモリ上のデータ構造で、かつデータは固定長であることがほとんどです。
これはぜひ可変長にせねばならんと思い、可変長のデータを入れられる設計にしました。

詳細

コンポーネントは大きく分けて Disk Manager, Buffer Pool Manager, Access Method の3部からなります。
トランザクションや複雑なクエリ言語は実装していないため、Lock Manager や Query Planner などはありません。
B+Tree の Access Method がほとんど直接インターフェースとして露出しています。
クエリのインターフェースとしては TCP 上で1行の JSON を送り合う素朴なプロトコルを採用しています。

Disk Manager

一番簡単なコンポーネントです。4KB 単位でバイト列を読み書きするだけです。説明不要でしょう。

qp/disk.rs at master · KOBA789/qp · GitHub

Buffer Pool Manager

すべてのスレッドが共有するバッファプールを管理します。
キャッシュ管理のアルゴリズムは LRU ではなく Clock Sweep です。PostgreSQL とかで使われているやつですね。
ちなみにバッファの free-list は実装していません。 free-list が一度枯れてしまったら DROP TABLE とかしないと復活しないわけですが、この DBMS には物理削除が実装されていないので不要という判断です。 起動直後にすべてのバッファを usage_count=0 で埋めてすべてを Clock Sweep に任せています。

意外と一番 Rust っぽいパズルがあったのがこの Buffer Pool です。 Rust の Mutex は保護対象のデータのコンテナのようになるため、無駄なロックを避けるためには Mutex に含める構造体のフィールドを最小限にする必要があります。 結果として細かな構造体がたくさん入れ子になり、深い階層構造を作っています。
前述の BusTub の実装と比べると対象的だと思います。

ページを fetch する際は BufferPool の巨大なロックを取り、ページテーブルで対象フレームが見つかったらフレームの中の Arc を clone して返します。
もし目的のページがフレームになければファイルから読み込んで victim フレームに書き込んでから、同様に Arc を clone して返します。
この状態ではページを Pin したのみでページのデータのバイト列へのアクセスはできませんので、実行スレッドが実際に読み書きする際は更に内側の RwLock を獲得してから操作します。

Mutex<
  BufferPool {
    frames: Vec<
      page_id,
      usage_count,
      body: Arc<
          RwLock<
            Page {
              is_dirty,
              data,
            }
          >
        >
      >
    >
  }
>

qp/buffer.rs at master · KOBA789/qp · GitHub

Access Method

公開しているコードに含まれている Access Method は B+Tree のみです。
フルスキャンしかできないテーブルヒープは消してしまいました。

qp/btree.rs at master · KOBA789/qp · GitHub

B+Tree の実装で特徴的な部分は特にありませんが、Latch Crabbing のために独自の latch 実装を書いています。
latch の実装としては parking_lot を使っているのですが、parking_lot の RwLock には owned な lock guard を獲得する方法がありません。
たとえば、tokio の Mutex には Arc<Mutex<T>> から owned な lock guard を獲得するメソッドがあります。
前述の通り、ページを Arc<RwLock<T>> で保護しているため、このようなメソッドがあるとロックを獲得したままデータを move できて便利です。
というか、ないと Latch Crabbing のようなテクニックを実現できません。

unsafe コードが含まれる唯一のモジュールです。

qp/lock.rs at master · KOBA789/qp · GitHub (lock.rs は名前がややこしいので latch.rs のほうがいいですね)

可変長のデータを固定長のページに格納するために Cell や Slot と呼ばれる構造があります。
それを実装したのが slotted.rs です。
もともとテーブルヒープを実装した際に書いたものですが、そのまま B+Tree の内部構造に流用しています。

qp/slotted.rs at master · KOBA789/qp · GitHub

DBMS を実装してみて

まだ全然要素が足りていないものの、実装をすることで Disk-Oriented DBMS のつらい部分を体験することができました。
既存の DBMS はすげーわと思うとともに、Disk-Oriented な DBMS をメニーコアでスケールさせるのはマジで無謀だわという実感を得ることもできました。
今後も暇を見つけて CC やリカバリなどを実装していければと思っています。

トランザクション中の文の失敗の扱いの違い

(読みづらいタイトルだな)

ことの発端はこのツイート。

さすがの MySQL でもそこを破ってくることはないだろうと思いつつ、トランザクション野郎としてはちゃんと確かめねばならないと思い、早朝にも関わらず布団から出てラップトップを開いた(午前10時)。

実験1

以下のような docker-compose.ymlsql/script.sql を用意し、実験をする。

version: '3.3'
services:
  db:
    image: mysql:8
    environment:
      MYSQL_DATABASE: test_db
      MYSQL_USER: user
      MYSQL_PASSWORD: password
      MYSQL_ROOT_PASSWORD: rootpassword
    volumes:
      - "./sql:/tmp/sql"
create table tbl(id int primary key);
insert into tbl values (1);
begin;
insert into tbl values (1);
insert into tbl values (2);
commit;

まずはおもむろに mysql のプロンプトを開き、sql/script.sql の内容を1行ずつ手で実行する。 (余計な warning などは消した)

# mysql -h 127.0.0.1 -u user -ppassword test_db
mysql> create table tbl(id int primary key);
Query OK, 0 rows affected (0.02 sec)

mysql> insert into tbl values (1);
Query OK, 1 row affected (0.01 sec)

mysql> begin;
Query OK, 0 rows affected (0.01 sec)

mysql> insert into tbl values (1);
ERROR 1062 (23000): Duplicate entry '1' for key 'tbl.PRIMARY'
mysql> insert into tbl values (2);
Query OK, 1 row affected (0.00 sec)

mysql> commit;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from tbl;
+----+
| id |
+----+
|  1 |
|  2 |
+----+
2 rows in set (0.00 sec)

なんと、insert into tbl values (1); が失敗したのにコミットできている。

一旦考察と仮説

トランザクション中の文が失敗しても、その失敗をクライアントに通知できているなら原子性違反にはならないのではないか。

つまり、クライアントに失敗を通知できない非対話実行(バッチ実行)では途中で実行が止まるはずである。

実験2(非対話実行)

上で作ったテーブルは適当に drop して、次の実験をする。

仮説では非対話実行では途中で実行が止まり、結果として id=1 の行しか残らないはずである。 (余計な warning などは消した)

# mysql -h 127.0.0.1 -u user -ppassword test_db < /tmp/sql/script.sql
ERROR 1062 (23000) at line 4: Duplicate entry '1' for key 'tbl.PRIMARY'
# mysql -h 127.0.0.1 -u user -ppassword test_db
mysql> select * from tbl;
+----+
| id |
+----+
|  1 |
+----+
1 row in set (0.00 sec)

仮説どおり、4行目のエラーで実行が止まり、1行だけのテーブルになった。

実験3(PostgreSQL)

さて、ここで気になるのは PostgreSQL の挙動である。

私は仕事でも趣味でも PostgreSQL やその派生を使うことが多い。

PostgreSQL は対話モードでも途中でコケたらコミット不能になるはずであるが、いい機会なのでちゃんと確かめておこう。

MySQL 同様、docker-compose を用いて適当に用意する。

version: '3.3'
services:
  db:
    image: postgres:12
    environment:
      POSTGRES_PASSWORD: password
    volumes:
      - "./sql:/tmp/sql"

そしてまずは対話実行で確認。

# psql -U postgres
postgres=# create table tbl(id int primary key);
CREATE TABLE
postgres=# insert into tbl values (1);
INSERT 0 1
postgres=# begin;
BEGIN
postgres=# insert into tbl values (1);
ERROR:  duplicate key value violates unique constraint "tbl_pkey"
DETAIL:  Key (id)=(1) already exists.
postgres=# insert into tbl values (2);
ERROR:  current transaction is aborted, commands ignored until end of transaction block
postgres=# commit;
ROLLBACK
postgres=# select * from tbl;
 id
----
  1
(1 row)

うむ、やはり途中でコケると後続の文は無視される。実家のような安心感。

ついでに非対話実行もためそう。

# psql -U postgres < /tmp/sql/script.sql
CREATE TABLE
INSERT 0 1
BEGIN
ERROR:  duplicate key value violates unique constraint "tbl_pkey"
DETAIL:  Key (id)=(1) already exists.
ERROR:  current transaction is aborted, commands ignored until end of transaction block
ROLLBACK
# psql -U postgres
postgres=# select * from tbl;
 id
----
  1
(1 row)

こちらも同様。納得。

まとめ

MySQL は対話実行だとトランザクション中の文がコケても勝手にトランザクションがアボートしたりせず、トランザクションを続行することができる。

MySQL でも非対話実行なら途中の文がコケるとバッチ全体の実行が停止する。

PostgreSQL を使っている身からすると MySQL のこの挙動は結構新鮮だった。

長いトランザクションの途中でも衝突する可能性のある INSERT を投機的に(?)実行して衝突の有無で分岐したりできるので便利かもしれない。

なお、PostgreSQL でそのようなことをしたい場合は SAVEPOINT を使って予防線を張っておくとよい。長大なトランザクションを失敗一撃で無に帰さずに済む。

追証用資料

適当な英語で書かれた README を同梱しているのでそのとおりにやれば試せます(たぶん)。

github.com

自宅の潤沢な計算資源に対して一時的にグローバルIPが欲しいとき

みなさんが夏の訪れを感じるのはいつですか。 私は自宅サーバーのファンの音がうるさくなったときです。

私が普段自宅サーバーでサービスを公開するときは1つだけのグローバル IP アドレスを使って Virtual Host でやりくりしています。 (ロードバランサとして k8s 上の Contour(Envoy) を使っています。便利ですよ)

しかし、HTTP(s) ではないサービスをちょっと露出させたかったりするときに困ります。

かといって固定 IP アドレスをたくさんもらえる ISP 契約というのは一時的な用途のために契約するのにはちょっとお値段が。

今回はそんな自宅サーバーでグローバル IP アドレスをちょっと使いたいときに使えるテクを紹介します。

TL;DR

AWS の Elastic IP を EC2 につけて、IPIP6 でトンネルする

やりかた

前提として、対象の自宅サーバーは IPv6 でインターネットに出られるものとします。

IPv4 connectivity しかない場合は IPIP6(4-over-6) を IPIP(4-over-4) に読み替えればできる気がします。

IPIP ではなく IPIP6 にしている理由はフレッツだと IPv6 の方が圧倒的に速いからです。

VPC の用意

まずは AWS 側で IPv6 を有効にした VPC を用意します。

既存のやつを流用してもいいし、新規で作ってもいいと思います。

アドレスプールは Amazon のやつでいいです。

サブネットに IPv6 の CIDR を設定するのも忘れずに。

Elastic Network Interface と Elastic IP の用意

次に Elastic Network Interface(ENI) を作ります。

サブネットはさっき作った VPC のやつをちゃんと選びましょう。

IPv4 Private も IPv6 も Auto-Assign でいいです。

Security Group は用途に合わせてよしなに。

ENI ができたら Elastic IP をアロケートして Associate してやります。

複数付けたければ Manage IP Addresses で Private IP を足して Associate します。

ここで気をつけなければならないのは、ENI に紐付けられる IP アドレスの数は EC2 によって制限されていることです。

この上限はインスタンスタイプによってちがうのでよく確認しましょう。

また、1つのインスタンスに紐付けられる ENI の数にも制限があるため、ENI を分割しても限界があります。

たくさん欲しい場合はインスタンスタイプを変えたりインスタンスを増やしたりする必要があるでしょう。

docs.aws.amazon.com

EC2 インスタンスの用意

用意した ENI をくっつけたインスタンスを立ち上げます。

VPC とサブネットが ENI と食い違っていると作成できないので注意しましょう。

また、OS は Amazon Linux 2 を使っておくのがオススメです。

Amazon Linux 2 以外だと複数の IP アドレスをいい感じにする設定がダルいので。

Ubuntu だとダルいという例: Make Secondary Network Interface Work in Ubuntu EC2 Instance

インスタンスタイプは前述のとおり ENI 数や IP アドレス数に合わせて選びましょう。

IP パケットを右から左(または左から右)に流すだけなので CPU もメモリも問題になりません。一番安いやつを選べばいいと思います。

t2.nano でも 2ENI x 2IP で 4IP いけるのでだいたいのケースで十分だと思います。

ディスクもどうせほとんど使わんし超小さくていいです。

IPIP6 トンネルを掘る

EC2 が起動したらトンネルを掘っていきます。

以下のような構成を想定しています:

  • 自宅側のトンネル終端はルーター(私は IX2105 使ってます)
  • 自宅側のトンネル終端の IPv6 アドレス: 2001:DB8::789
  • 自宅サーバーの CIDR : 192.168.0.0/24
  • 対象の自宅サーバーの Private IP アドレス(自宅内): 192.168.0.1
  • VPC の CIDR : 10.123.0.0/16
  • ENI の Private IPv4 アドレス: 10.123.0.1
  • ENI の Public IPv4 アドレス: 203.0.113.1
  • ENI の IPv6 アドレス 2001:DB8::EC2

適宜読み替えてください。

自宅側

先にトンネルの自宅側の設定をしましょう。

ここでは NEC の IX 系ルーターの設定例だけを紹介します。

tunnel sourceIPv6 アドレスがあるインターフェースに、ip unnumbered自宅サーバーがあるサブネットに繋がってるインターフェースにするとよいです。

interface Tunnel0.0
  tunnel mode 4-over-6
  tunnel adjust-mtu auto
  tunnel destination 2001:DB8::EC2
  tunnel source GigaEthernet0
  ip unnumbered GigaEthernet1
  ip tcp adjust-mss auto
  no shutdown

トンネルの設定を入れたらルーティングの設定もします。

ポリシールーティングを使って自宅サーバーからのトラフィックをトンネルに流すことにします。

ip access-list aws-public permit ip src 192.168.0.0/16 dest any
route-map aws-public permit 10
  match ip address access-list aws-public
  set interface Tunnel0.0
interface GigaEthernet1
  ip address 192.168.0.254/24
  ip policy route-map aws-public
  no shutdown

ここでは CIDR でガバっとやってますが、サブネット内に EC2 に流したいサーバーと流したくないサーバーがいる場合は /32 とかで細かくやることになると思います。

私は EC2 経由で公開するサーバーを置く専用の VLAN を切ってるのでガバっとやっています。

EC2 側設定

コマンドは sudo とか付けずに書きますが必要に応じて root でやるとか sudo つけるとかしてください。

まずは Linuxルーターっぽいことをするときに必ず必要なやつ。

sysctl -w net.ipv4.ip_forward=1

次に IPIP6 トンネルを作成します。今の時代は ip コマンドでなんでもできて便利。

# ip -6 tunnel add ipip0 mode ipip6 local <ENI IPv6 アドレス> remote <自宅終端 IPv6 アドレス>
ip -6 tunnel add ipip0 mode ipip6 local 2001:DB8::1 remote 2001:DB8::789 encaplimit none
ip link set ipip0 up

トンネルが掘れたらルーティングを設定します。

ip route add 192.168.0.0/24 dev ipip0

これで自宅宛てのトラフィックがトンネルに潜って抜けていくようになります。

試しに自宅サーバーの IPv4 アドレス宛に ping 送って試したりするとよいです。

ping 192.168.0.1

これで返ってこなかったら何かがミスっているので確認しましょう。

正しくトンネルを掘れてパケットが行って帰ってこられるようになったら NAT の設定をします。

Public IPv4 アドレス1つにつき、行きと帰りで計2行ずつ設定が必要です。

欲張ってたくさん Elastic IP を貼り付けたひとはがんばって設定しましょう。

# iptables -t nat -A POSTROUTING -s <自宅サーバー Private IP アドレス(自宅内)> -j SNAT --to-source <ENI Private IPv4 アドレス>
# iptables -t nat -A PREROUTING -d <ENI Private IPv4 アドレス> -j DNAT --to-destination <自宅サーバー Private IP アドレス(自宅内)>
iptables -t nat -A POSTROUTING -s 192.168.0.1 -j SNAT --to-source 10.123.0.1
iptables -t nat -A PREROUTING -d 10.123.0.1 -j DNAT --to-destination 192.168.0.1

これでインターネットから 203.0.113.1(ENI の Public IPv4 アドレス) を使って自宅サーバーにアクセスできるはずです。

このままだと EC2 インスタンスを再起動したときに EC2 側のトンネル設定が吹き飛ぶのでいい感じに永続化してください。

自宅サーバー側の設定

ハマりポイントとして、自宅内の DNSゾルバを見るようになってて名前解決ができねぇ、みたいなことがあったりします(ハマった)。

DNSゾルバが同じブロードキャストドメインにいるなら大丈夫なんですが、そうでない場合はポリシールーティングで全部 EC2 にぶん投げちゃってるので見えなくなって困ります(困った)。

ゾルバの設定を DHCP で配ってたりすると面倒ですね(面倒だった)。まぁうまくやってください。

まとめ

Elastic IP は文字通り Elastic なので、パッと調達して取り付けることができて便利ですね。

私は ISUCON の練習用 VM をチームメイトに貸すときに使っています。

一時的にしか使わないのに3台分のグローバル IP アドレスを調達するのってめんどうですからねぇ。

ではよき自宅サーバーライフを!

Special Thanks

このアイデアは同僚の id:sora_h にもらいました。 最初は真面目に暗号化された VPN を張ろうとがんばってたんですが

てかインターネットに出たいトラフィックなら無暗号の ipip tunnelやGREで十分よ

との助言でこのような雑トンネルとなりました。いやー賢いね。

WASIを叩くWASMを手書きしてLucetでHello, world!する

WASI がなんだとか WASM がなんだとかは詳しく説明しません。各位やって。

まず Lucet を入れる。

github.com

はい

Hello, world! するには fd_write があればよさそう。

wasmtime/WASI-api.md at master · CraneStation/wasmtime · GitHub

引数はだいたい見りゃわかる。が、__wasi_ciovec_t っつーのが引っかかる。

とはいえこれも見りゃわかる。

wasmtime/WASI-api.md at master · CraneStation/wasmtime · GitHub

バッファへのポインタとバッファの長さを持ってる構造体。

で、fd_write にはそいつのポインタを渡してやればいいということがわかる。

というところまでわかったら WAT を書く。S 式。

(module
  (import "wasi_unstable" "fd_write" (func $__wasi_fd_write (param i32 i32 i32 i32) (result i32)))
  (func $_start
    i32.const 1
    i32.const 16
    i32.const 13
    i32.const 24
    call $__wasi_fd_write
    drop)
  (memory 1)
  (data (i32.const 0) "Hello, world!\00\00\00\00\00\00\00\0d\00\00\00")
  (export "_start" (func $_start)))

import ってやつでリンクをやってて、data でメモリの初期値を埋めてる。export はエントリポイントの公開。

で、data に埋まってる初期値が大切で、ここに

"Hello, world!"(13bytes) + padding[0x00 0x00 0x00](3bytes) + buf address[0x00 0x00 0x00 0x00] (4bytes = i32) + buf length[0x0d 0x00 0x00 0x00](4bytes = i32)

が埋まっている。

buf address と buf length ってのが __wasi_ciovec_t の中身。

ので、スタックに

1  ;; stdout の fd
16 ;; buf address へのアドレス
13 ;; "Hello, world!" のバイト数。buf length とは別にこれも必要らしい
24 ;; `nwritten` へのアドレスっぽい。戻り値はスタックに返ってくるんじゃねーのかよ(スタックにも返ってくるんだよな)

というかんじで積んでやって、call $__wasi_fd_write してやる。

最後、スタックに nwritten と思しき値が残ってしまうのでそいつを drop で消し飛ばせば完了。

hello.wat みたいな名前で保存してやって、以下のように実行できる。

lucetc-wasi -o hello.so hello.wat
lucet-wasi hello.so

楽しいね

デカいテレビとXbox One Xを買ってランボルギーニを乗り回す

f:id:koba789:20181216034813j:image

はい

 

サイバーマンデーにそそのかされてデカいテレビを買いました。

2018年も暮れなので、4K HDR な OLED です。ナウい

サイズは65インチ。デカい。

なお部屋は8畳1K。狭い。

 

で、テレビだけ買っても 4K HDR コンテンツを吐ける箱がなけりゃ意味なかろうということで、Xbox One X という箱を買いました。

Forza Motorsport 7 と Forza Horizon 4 というブーブーゲームが付いてるモデル。

 

私が暮らしている恵比寿(正確には違うが)という街は大変イカれた場所で、昼飯を食いに外に出るだけでランボルギーニを眺められるようなシティなのですが、デカいテレビと箱があるとなんと外に出ずとも部屋でランボルギーニを運転できます。

はえらい。

 

冒頭の画像はランボルギーニラカンで崖から飛び降りる瞬間を写したもの。

ゲームの世界では一軒家並みの値段の車で紐なしバンジーしても無料。

はえらい。

 

私は人間を歩かせたり武器を構えて何かを射るようなゲームが苦手なのですが、Forza は人間を歩かせたり武器を構えて何かを射たりということがないので助かっています。

 

ちなみに据え置きのゲーム機を手に入れるのはこれがやっと2台目で、ちなみに1台目は2ヶ月前に中古で買った Wii です。

携帯ゲーム機はというと DSi をバイト先の忘年会のビンゴ大会で当てたのが最後。

ゲーム機ってゲームできてすごいですね。

 

現実世界に置いてあるテレビの方は、現在こたつの上に曖昧に乗せてあるだけという状態で邪魔だしデカいしデカいし邪魔なので、いろいろして壁掛けにする算段です。

これもいい感じにシュッとしたら記事にしたい。

 

なお、テレビの登場によって今まで使っていたプロジェクターは失職となりました。

WXGA のくせに今までよく頑張ったと思います。解像度はともかくとしてランプがクソ明るかったのはよかった。

 

最後は崖からゴロゴロと転がってボコボコになり、リアスポイラーがバイバイしてしまった WRX STI で締めようと思います。

f:id:koba789:20181216040808j:image

ちなみに耳も取れてる👂

OBS Studioで使えるHDMIキャプチャを5000円で手に入れる

ヤァヤァ、KOBA789 であるぞ。

今回はおもしろハードウェア活用術のご紹介です。

安い HDMI キャプチャデバイスが欲しい

欲しい。欲しいじゃないですか。

ゲームにしろカメラの映像にしろ、なんらかの映像をオンラインで生配信したいと思ったとき、真っ先に必要になる機材が HDMI キャプチャデバイスです。
日常的にパソコンやってると USB Audio IF とか、ウェブカムとか、そういうのは案外持ってるもんですが、HDMI キャプチャは持ってないがち。

しかし HDMI キャプチャデバイスというのはあまり一般的なデバイスではないので案外高い。
いくら安くても1万は切れないことが多くて、まぁ2〜3万は覚悟が必要になるわけです。

映像ソースが1本しかないのであれば、まぁ買い切りだし1個くらい買ってもいいか、となるもんですが、これが2本、3本となると話は別です。
ちょっときつい。
ロスレスなんて言わないから、なんなら60fpsも諦めるから、安いやつが欲しい、そういうケースはあります。

たとえば勉強会の配信とか。
スライドなんて数十秒に1回しか切り替わらんし、演台の人間もほとんど動かないので60fpsは要らない。
大したディティールも要らんのでキャプチャデバイスロスレスだろうとロッシーだろうと、配信を見てる人にはほとんど差がわからない。

あと、登壇者のマシンとカメラが離れてるとか、そういうこともあって映像を数十メールくらい伝送する必要があったりします。
20m の HDMI のケーブルとか結構いいお値段するし、太くて取り回しはだるいし、かといって安いケーブルだと信号がロスってちゃんと伝送できないこともあります。

こういう願いを全部叶えてくれる、ナイスなデバイスないかな〜〜と思ってたら、あったんです。

LKV373

自称「120m HDMI extender over CAT5/5e/6」
自称というか、ちゃんと HDMI 延長器として銘を受けて生まれてきたデバイスです。本来は送信側と受信側をペアにして使います。

ただこのデバイス、over CAT5/5/6 とか言いながら、実は LAN ケーブルに律儀に IP を流しています。
中に流れているパケットの正体は細切れになった JPEG の断片を含む独自プロトコルUDP のパケットなので、受信側を外して代わりに一般的な NIC を搭載したコンピュータを繋ぐと映像を容易に受信することができます。
nJPEG に圧縮されているので原理的にはロッシーですが、言うほど劣化は目立たなくて、許せるレベル。

当然 CAT5 とかのいわゆるふつーの LAN ケーブルを使えるわけので、数十メール伝送するくらい楽勝ですし、ケーブルも安いしどこでも買える。

詳しいことはこの記事に書いてあるので、興味のある人はぜひ。

Reverse engineering Lenkeng HDMI over IP extender | danman's blog

一般的な NIC で容易に受信可能とはいえ、中身は独自のプロトコルなので、既存のソフトウェアにそのまま食わせることはできません。
生配信用のデファクトスタンダードである OBS Studio なんかに食わせられると最高なんですが、もうひと手間必要です。

OBS Studio 用のプラグインを書く

OBS Studio にはプラグイン機構があり、仕様に沿った動的リンクライブラリを用意してやると機能を拡張することができます。
拡張できる機能にもエンコーダとか映像入力とか、いくらか種類があります。

今回は映像を入力したいので、Source という機能を持つプラグインを実装しました。

実装の方法は至って簡単で、 libobs/obs-module.h というヘッダファイルのインターフェースに従って必要な関数を実装、export するだけです。
だいたいのことはこのページを読めばわかります。

Plugins — OBS Studio 20.1.0 documentation

普通なら実装言語は当然C言語ですが、2018年も末になって新規でC言語を書きたくなかったので、Rust で書くことにしました。

Rust は C++ などと同様、C言語の呼び出し規約と互換性のある動的リンクライブラリを生成することができるので、better C として使うことができます。

まずは libobs とリンクする必要があるため、Rust のビルドシステムである cargo に、libobs のビルドシステムを組み込んでやる必要があります。
libobs はビルドシステムとして cmake を利用しているのですが、cmake という crate を使うとシュッと連携させることができます。ほんと便利。
さらに、バインディングは頑張って手で書かなくても bindgen という crate を使うことでヘッダファイルから自動生成できます。マジで優秀。
というわけで、だいたい全部自動で準備が整います。

動画の JPEG フレームをデコードするため、同様の方法で、libjpeg-turbo もリンクしてやります。
Pure Rust な実装の JPEG デコーダもあるのですが、インターフェースの兼ね合いなどもあって断念しました。
libjpeg-turbo は SIMD 命令などを活用することで JPEG の高速な圧縮と伸張ができるライブラリです。
このライブラリを選んだ一番の理由は、出力形式として libobs と互換性のある BGRX フォーマットが使えたことです。
ピクセルエンディアンJPEG デコーダと libobs でズレていると、毎フレームごとにフォーマットを揃える必要があり、実行速度上のペナルティが大きくなります。
動画のフレームのデコードは速度が命なので、これはとても大切でした。

で、できあがったものがこちらです。

github.com

今は macOS でしかビルドを確認していませんが、ちょっと編集すれば Linux でもビルドできるんではないかと思っています。

使い方

  1. ビルドできたライブラリを、OBS Studio のプラグインディレクトリに入れる
  2. PC に NIC を刺す
  3. NIC の IP アドレスを 192.168.168.0/24 のうちの任意に設定
  4. OBS を起動
  5. 入力ソースとして "LENKENG" を追加
  6. Interface Address に3で設定した IP アドレスを設定
  7. たぶんこの時点では動かないので OBS を再起動
  8. 動く

7 でちゃんと動かないのは、俺が実装をサボったからです。 誰か source_info.update をちゃんと実装して Pull Request を投げてください。

おわり