HaskellでのWebSocketsサーバの効率改善

前回の記事で、HTMLのCanvas要素とWebSocketsの組み合わせで画像転送と表示の実験を行いました。 想定よりもHaskellで作ったWebSocketsサーバの動作が重いため、少し調査して改善しました。

import Control.Exception (finally)
import Control.Monad (forM_, forever)

import qualified Network.WebSockets as WS
import qualified Data.ByteString.Char8 as C
import qualified Data.ByteString.Lazy as L
import qualified Data.ByteString.Internal as BI

import Foreign.Ptr (plusPtr, Ptr)
import Foreign.Storable (poke)
import Data.Word (Word8)

main :: IO ()
main = WS.runServer "127.0.0.1" 9160 application

-- BI.create :: Int -> (Ptr Word8 -> IO ()) -> IO ByteString
fill :: Int32 -> Ptr Word8 -> IO ()
fill seed ptr = 
    go (0, 0) ptr
      where
        go (j, i) ptr = do
            let val = fromIntegral $ seed + i + j
            poke ptr val
            if i == 1919 then
                if j == 1079 then
                    return ()
                else
                    go (j+1, 0) $ ptr `plusPtr` 1
            else
                go (j, i+1) $ ptr `plusPtr` 1

application :: WS.ServerApp
application pending = do
    conn <- WS.acceptRequest pending
    WS.forkPingThread conn 30
    forever $ do
        bytes <- WS.receiveData conn
        let seed:_ = L.unpack bytes
        let seed2 = fromIntegral (toInteger seed)
        buf <- BI.create (1920*1080) (fill seed2)
        WS.sendBinaryData conn buf

ByteString.Internalのcreate関数を使って、転送するバッファの生成と初期化を同時に行います。 fill関数には、createで確保される領域へのポインタが渡されるので、poke関数を使って当該領域に1つずつデータを書き込みます。 go関数で、1画面分ループします。1ループごとにポインタを1進める(plusPtr)ことで、次のWord8にアクセスできます。

このプログラムを試してみると、FirefoxはCPUを60%程度消費する一方、サーバ側は15%程度で済んでいます。

クライアントからリクエストがあるごとに毎回create関数を呼び出しているので、 あまり効率は良くないと思いますが、それでも初期のサーバ律速であった状況からは大幅に改善しました。

もっとも、このようなプログラムにしてしまうと、中身はCで記述するのと大差ないので、 あえてHaskellで書く意義はかなり失われている感があります。

HTMLのCanvasとHaskellでのWebSocketsを組み合わせる

前回、HTMLでCanvasを使用した描画を試しました。 今回は、HaskellでWebSocketsのサーバを作成し、当該サーバからバイナリデータを受信してCanvasに描画するようにしました。

まずは、ブラウザで動作させるファイルです。

<html>
    <body>
        <script
            src="https://code.jquery.com/jquery-3.2.1.js"
            integrity="sha256-DZAnKJ/6XZ9si04Hgrsxu/8s717jcIzLy3oi35EouyE="
            crossorigin="anonymous"></script>
        <p><div id="time">Time[ms]</div></p>
        <canvas id="canvas" width="1920" height="1080"></canvas>
        <script type="text/javascript">
            $(window).on('load', function(){
                var tm = 0;
                var canvas = $('#canvas').get(0);
                var width = canvas.width;
                var height = canvas.height;
                var ctx = canvas.getContext('2d');
                var imageData = ctx.getImageData(0, 0, width, height);

                var buf = new ArrayBuffer(imageData.data.length);
                var buf8 = new Uint8ClampedArray(buf);
                var data = new Uint32Array(buf);
                var start_ms = performance.now();
                var elapsed_ms;
                var cn = new WebSocket('ws://127.0.0.1:9160/');

                cn.addEventListener('message', function(ev){
                    var reader = new FileReader();
                    reader.addEventListener("loadend", function(){
                        var ary = new Uint8Array(reader.result);
                        //console.log(ary[0]);
                        for (var j = 0; j < height; j++){
                            for (var i = 0; i < width; i++){
                                var val = ary[j*width+i];
                                data[j*width+i] = (255<<24) | (val<<16) | (val<<8) | val;
                            }
                        }
                        imageData.data.set(buf8);
                        ctx.putImageData(imageData, 0, 0);
                        if (tm % 60 == 0){
                            var current = performance.now();
                            elapsed_ms = current - start_ms;
                            $("#time").text(elapsed_ms/60.0 + "[ms]");
                            start_ms = current;
                        }
                    });
                    reader.readAsArrayBuffer(ev.data);
                });

                var getData = function(){
                    var abuf = new ArrayBuffer(1);
                    var view = new Uint8Array(abuf);
                    view[0] = tm & 0xFF;
                    cn.send(abuf);
                    tm++;
                    setTimeout(getData, 1);
                }
                cn.addEventListener('open', function(){
                    getData();
                });
            });
        </script>
    </body>
</html>

上記のとおり、ローカルホストで動作するWebSocketサーバとの通信を開き、getData関数を繰り返し呼び出します。

getData関数では、1バイトの配列要素に、0-255で巡回する値を入れて、WebSocketサーバに送ります。

また、受信するmessageのイベントハンドラでは、ev.dataがJavaScriptのBlobなので、readAsArrayBufferでloadenedハンドラと結びつけます。 当該ハンドラでは、reader.resultがサーバから送られてきた生データの配列になります。これを前回の記事と同様、Canvasに描画します。

一方HaskellでのWebSocketsサーバは次コードです。

{-# LANGUAGE OverloadedStrings, ScopedTypeVariables #-}
module Main where

import Lib
import Data.Int
import Control.Exception (finally)
import Control.Monad (forever)
import qualified Data.Text as T
import qualified Data.Text.IO as T

import qualified Network.WebSockets as WS
import qualified Data.ByteString.Char8 as C
import qualified Data.ByteString.Lazy as L

main :: IO ()
main = WS.runServer "127.0.0.1" 9160 application

application :: WS.ServerApp
application pending = do
    conn <- WS.acceptRequest pending
    WS.forkPingThread conn 30
    forever $ do
        bytes <- WS.receiveData conn
        let seed:_ = L.unpack bytes
        let seed2 = fromIntegral (toInteger seed)
        WS.sendBinaryData conn (L.unfoldr (genData seed2) (0, 0))

genData (seed :: Int32) (j, i) =
    if j >= 1080 then Nothing
    else
        if i == 1919 then
            Just (fromIntegral (seed+i+j), (j+1, 0))
        else
            Just (fromIntegral (seed+i+j), (j, i+1))

何かデータを受信したら、先頭バイトを取り出し、Word8に変換して(fromIntegral . toIntegerの組み合わせ)、 unfoldrにgenData関数を渡して(初期値としてWord8を渡す)、ByteStringを生成させます。

genData関数がNothingを返すと、ByteStringの生成は終了されます。 Just (a, b)を返すと、aがByteStringの新しい要素となり、bがgenDataを再度呼び出すために用いられます。

結局、(0, 0)からスタートして、(1079, 1919)まで、(y座標, x座標)の組が引数として渡されるようにしています。

生成されたByteStringはsendBinaryData関数でクライアントに返されます。

WindowsにStackを使ってGHCや必要なライブラリをインストールし、サーバを動作させて、 Firefoxで同一マシンからアクセスしてみました。

…かなり遅い。前回はFirefoxのプロセスが30%程度CPUを消費していましたが、今回は15%未満でした。 その代わり、WebSocketsのサーバを動作させているコマンドプロンプトのプロセスが50%消費しています。 どうやらサーバ側が律速しているようです。

まだ詳細に調査していませんが、sendBinaryDataで単純にファイルの中身のようなデータを送る場合は、 もっと高速に動作している印象でした。そのことからすると、genDataの繰り返しが遅いのではないかと想像しています。

2017/12/20追記:ちょっと調べたところ、ByteStringはBuilderを使って生成したほうが速いらしいので、 Haskell側のプログラムを以下のように修正しました。

{-# LANGUAGE OverloadedStrings, ScopedTypeVariables #-}
module Main where

import Lib
import Data.Int
import Control.Exception (finally)
import Control.Monad (forM_, forever)
import qualified Data.Text as T
import qualified Data.Text.IO as T

import qualified Network.WebSockets as WS
import qualified Data.ByteString.Char8 as C
import qualified Data.ByteString.Lazy as L
import qualified Data.ByteString.Builder as B

main :: IO ()
main = WS.runServer "127.0.0.1" 9160 application

infixr 4 <>
(<>) = mappend

application :: WS.ServerApp
application pending = do
    conn <- WS.acceptRequest pending
    WS.forkPingThread conn 30
    forever $ do
        bytes <- WS.receiveData conn
        let seed:_ = L.unpack bytes
        let seed2 = fromIntegral (toInteger seed)
        WS.sendBinaryData conn (B.toLazyByteString (genData seed2 (0, 0)))

genData (seed :: Int32) (j, i) =
    if i == 1919 then
        if j == 1079 then
            B.word8 (fromIntegral (seed+i+j))
        else
            B.word8 (fromIntegral (seed+i+j)) <> genData seed (j+1, 0)
    else
        B.word8 (fromIntegral (seed+i+j)) <> genData seed (j, i+1)

大枠は同じですが、unfoldrの代わりに、toLazyByteStringを使用し、 genDataはword8を使用してBuilderを生成します。各画素のBuilderはmappend(<>)にて連結されます。

これで試してみたところ、Firefoxは30%程度まで負荷が上がったので、効率は多少上がったかもしれません。 それでも、まだHaskell側が50%消費しています。ごく単純なプログラムなので、これはいただけません。

仕組みの検証はできましたが、もう少し速くしたいところです。 今度はRustあたりで作成してみようかと検討しています。

2017/12/21追記ByteStringあれこれを見ると、 Internalを使わないとパフォーマンスを出すのは難しそうですね。 mallocByteStringしてからscanrなどで置き換えていくのが良いのかもしれません。 また別途試してみたいと思います。

HTMLのCanvas要素の描画性能を調べてみる

HTMLのCanvas要素にビットマップを描画する場合、どの程度の性能がでるのか、試してみました。

<html>
    <body>
        <script
            src="https://code.jquery.com/jquery-3.2.1.js"
            integrity="sha256-DZAnKJ/6XZ9si04Hgrsxu/8s717jcIzLy3oi35EouyE="
            crossorigin="anonymous"></script>
        <p><div id="time">Time[ms]</div></p>
        <canvas id="canvas" width="1920" height="1080"></canvas>
        <script type="text/javascript">
            $(window).on('load', function(){
                var tm = 0;
                var canvas = $('#canvas').get(0);
                var width = canvas.width;
                var height = canvas.height;
                var ctx = canvas.getContext('2d');
                var imageData = ctx.getImageData(0, 0, width, height);

                var buf = new ArrayBuffer(imageData.data.length);
                var buf8 = new Uint8ClampedArray(buf);
                var data = new Uint32Array(buf);
                var start_ms = performance.now();
                var elapsed_ms;

                var draw = function(){
                    for (var j = 0; j < height; j++){
                        for (var i = 0; i < width; i++){
                            var val = (i+j+tm) & 0xFF;
                            data[j*width+i] =   (255 << 24) |
                                                (val << 16) |
                                                (val << 8) |
                                                val;
                        }
                    }
                    imageData.data.set(buf8);
                    ctx.putImageData(imageData, 0, 0);
                    tm++;
                    if (tm % 60 == 0){
                        var current = performance.now();
                        elapsed_ms = current - start_ms;
                        $("#time").text(elapsed_ms/60.0 + "[ms]");
                        start_ms = current;
                    }
                }
                setInterval(draw, 5);
            });
        </script>
    </body>
</html>

setIntervalに小さめの値を設定することで、できるだけ頻繁にdraw関数での描画が実行されるようにしました。

試してみると、ジッタがありますが、12-15[ms]程度に1回の描画が行われているようです。 試した環境は、Core i7-6500U, Windows 10, Firefox 57.0.2 (64bit)です。 動作中は、Firefoxのプロセスが30%程度のCPU時間を消費し、GPUは55%程度を示しています(タスクマネージャで計測)。

以上から、この程度のコードでも、ハイビジョン解像度で60fps程度の性能は達成できそうだということが分かりました。

参考:Fast pixel drawing on canvas

WinUSBドライバの自動インストール

WindowsにはWinUSBというドライバフレームワーク(?)があります。これを使えば、 自作USBデバイスと通信するドライバを自作する必要なく、 アプリケーションレベルでバルク転送などの通信を行うことができます。

というのは組み込み業界では良く知られていると思います。でも、 比較的最近可能になった裏技(?)についてはあまり知られていないのではないかと思います。

これまでは、WinUSBのドライバをインストールするためにinfファイルを作ったりする必要がありました。 でも実はWindows8以降からは、infファイルさえ必要なく、デバイスを接続するだけでドライバをインストールすることが可能になっています。

つまり、ドライバ署名なども一切気にする必要はないのです。ドライバ署名のチェックを無効にして再起動するという前時代的な作業は不要です。

これを実現するには、USBデバイス側にも少し変更が必要です。具体的には、 Microsoft OSディスクリプタというものを作成し、Windowsからのリクエストに応じて返すようにします。

Microsoft OS descriptorには2種類存在します。

  • 標準USBストリングディスクリプタ。OS string descriptorと呼ばれます。 このディスクリプタにより、デバイスから(次に述べる)OS feature descriptorを取得可能であるとOSが判断します。
  • OS feature descriptor: デバイスは1つまたは複数のOS feature descriptorを持つことができます。

OS feature decriptorを取得するための手順は次のようになります。

  1. まず、WindowsがOS string descriptorを取得するためのコントロール要求を送る
  2. Windowsは有効なOS string descriptorであることを検証する
  3. WindowsはOS string descriptorのbMS_VendorCodeフィールドの情報を用いて、OS feature descriptorを取得する

OS feature descriptorには次の種類があります。

  • Extended Compat ID この情報に基づき、Windowsはどのドライバをロードするかを決定します。
  • Extended Properties
  • Genre これはHIDデバイスによって使用される(予定)。

とりあえず、上記二つのExtended Compat ID, Extended Propertiesのディスクリプタを準備すれば、 ドライバの自動インストールと、GUIDによるアプリケーションからの通信が可能になります。

OS string descriptor

OS string descriptorは標準ストリングディスクリプタの、string index 0xEEに格納されます。 OS string descriptorはデバイスにつき1つのみ持つことができます。

OS string descriptorを取得するために、GET_DESCRIPTORコントロール要求がデバイスに送られます。

  • bmRequestType=0x80
  • bRequest=GET_DESCRIPTOR
  • wValue=0x03EE
  • wIndex=0x0000
  • wLength=0x12

OS string desciptorの要求に応じる場合、デバイスはDataフィールドにdescriptorを返します。 Version 1.00のOS string descriptorは18バイトの固定長であり、次から成ります。

  • bLength (1Byte): 0x12
  • bDescriptorType (1Byte): 0x03
  • qwSignature (14Bytes): ‘MSFT100’
  • bMS_VendorCode (1Byte): Vendor-specific
  • bPad (1Byte): 0x00

qwSignatureはUnicode文字です。

bMS_VendorCodeは、関連するfeature descriptorを取得するために以降用いられます。

(Windows 8とWindows Server 2012では、USB3.0複合デバイスでbMS_VendorCodeに0x00を指定するものは、列挙に失敗します)

続いて、OS feature descriptorを取得するためにGET_MS_DESCRIPTORリクエストが送られます。

  • bmRequestType=0xC0(デバイスの場合), 0xC1(インタフェースの場合)
  • bRequest=GET_MS_DESCRIPTOR(この値は上記bMS_VendorCode)
  • wValue=0x0000(デバイスの場合。インタフェースの場合、下位バイトがインタフェース番号)
  • wIndex=要求されるOS feature descriptorの種類。次の値のいずれか
  • Genre: 0x0001
  • Extended Compat ID: 0x0004
  • Extended Properties: 0x0005
  • wLength=Dataフィールドのバイト数。最大4KB。初回の要求では、wLengthは0x10に設定され、デバイスはヘッダ部分のみを返します。

Extended compat ID OS feature descriptor

デバイスは1つのExtended Compat IDディスクリプタしか持てません。 このディスクリプタは、固定長のヘッダーセクションと、ヘッダーセクションに続く1つ以上の機能セクションから成ります。

各機能セクションは、特定のインタフェース、機能、インタフェースの機能グループと関連します。 デバイスが返すbCountフィールドは機能セクションの数となります。

Windowsは、まずExtended Compat IDディスクリプタのヘッダ部のみを要求し、ヘッダのdwLengthフィールドを用いて、 再度GET_MS_DESCRIPTORリクエストを送ります。このときのwLengthにはdwLengthの値が入ってきます。

ヘッダーセクションは

  • dwLength (4bytes)
  • bcdVersion (2bytes)
  • wIndex (2bytes)
  • bCount (1byte)
  • RESERVED (7bytes)

から成ります。

extended compat ID descriptorの場合、wIndexは上記のとおり0x0004です。bcdVersionは、1.00だと0x0100にします。 RESERVEDは0x00で埋めます。

機能セクション

機能セクションは

  • bFirstInterfaceNumber (1byte)
  • RESERVED (1byte)
  • compatibleID (8bytes)
  • subCompatibleID (8bytes)
  • RESERVED (6bytes)

から成ります。最初のRESERVEDは0x01固定です。最後のRESERVEDは0x00で埋めます。 機能セクションはヘッダーセクションの後に、インタフェース番号順に続きます。 したがって、最初の機能セクションのbFirstInterfaceNumberは0x00となります。

WinUSBドライバを関連付けたい場合、compatibleIDは0x57 0x49 0x4E 0x55 0x53 0x42 0x00 0x00を入れます。 これはASCII文字列’WINUSB’と、残りを0x00で埋めたものです。 WinUSBの場合、subCompatibleIDは全て0x00で良いようです。

Extended Properties OS feature descriptor

次に、Extended Properties OS featureディスクリプタでGUIDを指定することで、 アプリケーションが当該GUIDを指定することで、 接続されているデバイスと通信するためのエンドポイントを開くことができるようになります。

このディスクリプタもヘッダーセクションとカスタムプロパティセクションから成ります。

ヘッダーセクションは

  • dwLength (4bytes)
  • bcdVersion (2bytes)
  • wIndex (2bytes)
  • wCount (2bytes)

から成ります。これに続くカスタムプロパティセクションは

  • dwSize (4bytes)
  • dwPropertyDataType (4bytes)
  • wPropertyNameLength (2bytes)
  • bPropertyName (上記wPropertyNameLength bytes)
  • dwPropertyDataLength (4bytes)
  • bPropertyData (上記dwPropertyDataLength bytes)

となります。GUIDを指定するためには、上記bPropertyNameに’DeviceInterfaceGUID’を指定し、 bPropertyDataに所望のGUIDを指定します。両方ともUnicode文字列です。 dwPropertyDataTypeは0x00000001を指定します。

続きはパッチで

Extended Properties OS feature descriptorの残りは、 Zynqで動作するUSBデバイスの改造方法のパッチを置いておきますので、そちらで確認してください。

これは、Vivadoを標準的な場所にインストールした時に、 C:\Xilinx\SDK\2017.1\data\embeddedsw\XilinxProcessorIPLib\drivers\usbps_v2_4\examples にインストールされている、USB Mass Storageとして動作するファームウェアに対するパッチとなります。 この改造により、Mass Storageとしてではなく、 バルクIN, OUTエンドポイントを一つずつ持つWinUSBデバイスとして見えるようになります。 あとはバルク転送のファームウェアの部分を改造すれば、PC側のソフトウェアと合わせて任意の通信ができるようになります。

ちなみに、Windowsはデバイスのディスクリプタをキャッシュしてしまうので、“多分動作するように修正したはずなのに、 なぜか動作しない"といった場合、とりあえずVendor ID/Product IDを変更することで、 別のデバイスとしてWindowsに認識させると動いたりします。

パッチはこちら

Windows環境でFormsアプリを作るなら、C++が最強ですよ

Windows環境でアプリケーション(Webアプリではなく、Formsアプリ)を作る場合、 最近ではC#を使うのが典型的となっているのではないかと思います。 必要に応じてC++やCで開発したDLLを呼び出したり、あるいはC#から直接DLLを呼び出すかもしれません。 マニアックな方であれば、C#ではなくてF#を使ったりするかもしれません。

業務上作成するアプリケーションはできる限りのパフォーマンスが欲しい、という要求が生じることがあります。 そうなると、現状ではC++/CLIでアプリケーションを作るのが最も良いのではないかと思います。

C++/CLIで実装すれば、WindowsのFormもグラフィカルデザイナーで作成できますし、 必要であれば.NETコードではなくてネイティブコードを生成するようコンパイラオプションで設定することもできます。 しかも.NETとネイティブコードとのデータのやり取りは、関数呼び出しで行う限りほぼ暗黙的に実行できます。

C++であればOpenMPもC++ AMPも自由自在です。そのため、GPGPUやマルチスレッドプログラムの作成も (APIを呼び出すことなく)比較的簡便に行うことができます。

個人的にはC++なんて醜悪言語は使いたくないですが、パフォーマンスのためには背に腹は代えられません。

« 2/2