前兩週用 VSCode 寫 Arduino C++ 完成 OLED 顯示器 I2C vs SPI 效能評測,得到 9.8s vs 1.2s 的評測結果。得到 I2C 的效能數據,下一個我最想知道的便是「改用 nanoFramework / C# 會慢多少?」。

基於語言特性,要拼效能,Python、.NET、PHP 這類高階語言都不會是 C/C++ 的對手,但究竟差距有多大,讓我好奇。然而,執行效能不是選擇程式語言的唯一考量,只要效能差距別太離譜,nanoFramework 具有內建資料型別、API 較豐富,開發環境成熟 (Visual Studio 既出,誰與爭鋒),提供記憶體及執行緒管理,對於效能要求不嚴苛的應用,能省下可觀的學習、開發及除錯時間。

花了點功夫,將 Arudino SSD1306 驅動程式庫翻寫成 C# 版,在 nanoFramework 復刻上次的天竺鼠 Logo 滑入動畫與 0-255 數字列印測試:

Ssd1306 oled = new Ssd1306();
oled.Initialize();
oled.ClearScreen();
for (int i = 128; i >= 32; i--)
{
    oled.DrawImage(i, 0, 64, 64, guineapig_logo);
    oled.Display();
}
oled.ScrollUpLine();
oled.Display();
oled.CursorY = 56;
for (int i = 0; i < 256; i++)
{
    oled.Print($"{i} ");
}
oled.PrintLine();
TimeSpan dura = DateTime.UtcNow - st;
oled.PrintLine($"I2C Test: {dura.TotalMilliseconds:n0}ms");

第一次跑完我有點吐血,居然花了 61 秒。不過,沒關係,先分析慢在哪裡,才知怎麼改。nanoFramework 沒有 Stopwatch 可用,一不做二不休,我把之前發明的極簡風格 .NET Stopwatch 計時法搬進來,計時部分改用開始結束的 DateTime.UtcNow 相減取 TotalMilliseconds。先觀察像蝸牛爬的從右滑入動畫:

for (int i = 128; i >= 32; i--)
{
    using (var ts1 = new TimeMeasureScope($"DrawImage at X:{i}"))
    {
        oled.DrawImage(i, 0, 64, 64, guineapig_logo);
        using (var ts2 = new TimeMeasureScope("Send Data to SSD1306"))
        {
            oled.Display();
        }
    }
}

由 Debug Log 結果得知顯示一次圖要 359ms,其中 DrawImage() 是設定像素 byte[],耗時約 250ms,Dispaly() 是將 byte[1024] 透過 I2C 送到 OLED,耗時約 106ms:

...
DrawImage at X:37|359ms
Send Data to SSD1306|106ms
DrawImage at X:36|358ms
Send Data to SSD1306|106ms
DrawImage at X:35|359ms
Send Data to SSD1306|106ms
DrawImage at X:34|359ms
Send Data to SSD1306|106ms
...

先查 I2C 送資料部分,發現我犯了一個錯,我用到 I2cBusSpeed.StandardMode 而非 I2cBusSpeed.FastMode:

new I2cDevice(new I2cConnectionSettings(busId, i2cAddr, I2cBusSpeed.StandardMode));

修改之後,Send Data to SSD1306 降到 35ms。但總時間仍要 290ms,等於一秒才刷新三次,從 128 移到 32 超過 30 秒。DrawImage() 的寫法如下:

public void DrawImage(int x, int y, int w, int h, byte[] bitmap)
{
    var rowByteCount = w / 8 + (w % 8 > 0 ? 1 : 0);
    for (int i = 0; i < w; i++)
    {
        for (int j = 0; j < h; j++)
        {
            SetPixel(x + i, y + j, (bitmap[j * rowByteCount + i / 8] << (i % 8) & 0x80) > 0);
        }
    }
}

public void SetPixel(int x, int y, bool on = true)
{
    if (x < 0 || x > WIDTH - 1) return;
    if (y < 0 || y > HEIGHT - 1) return;
    int index = (y / 8) * WIDTH + x;
    if (index > buffer.Length - 1) return;
    if (on)
        buffer[index] = (byte)(buffer[index] | (byte)(1 << (y % 8)));
    else
        buffer[index] = (byte)(buffer[index] & ~(byte)(1 << (y % 8)));
}

四平八穩的寫法跟 C++ 版本相去不遠,卻有明顯速度差異,要加速就只能靠 unsafe 了。

查到在 .nfproj 加入 <AllowUnsafeBlocks>true</AllowUnsafeBlocks> nanoFramework 也能寫 unsafe,讓我高興了一下。不料,改好的程式雖然可以編譯,但執行會爆出奇怪錯誤,甚至直接閃退。

在 nanoFramework Discord 社群發問,得到熱心回答(還遇到 nanoFramework 創始成員,也是微軟 MVP 的 José Simões),用破爛雞同鴨講了一陣子,最後我確認一點:nanoFramework 雖然支援 unsafe,但能做的事還很侷限,不是所有動作都支援,低階 byte[] 指標存取便是其一,美夢破碎。結論是,目前 nanoFramework 遇到 byte[] 效率不佳還無法用 unsafe 加速。

不甘心在這裡就止步,我做了不理性的事 (正常人應該要放棄回去用 C++) - 花了時間想更複雜的演算法破突困境。

由測試過程可以看出我寫的文字列印速度比圖形顯示快很多,分析背後的原因是字型資料 byte[] 是採 Y 軸每 8 點存一個 Byte,與顯示器記憶體格式一致,故每搬一個 Byte 可以複製 8 個像素(必要時要拆兩部分分別寫入對映的上下 Byte);而圖案 Bitmap 則是 X 軸 8 點一個 Byte,故必須以像素為單位逐 Bit 處理。針對需反覆顯示的圖形,若預先將 Bitmap byte[] 也轉成 Y 軸 8 點一個 Byte,就能減少 Byte 寫入次數可由 8 次降到 2 次以下,儘可能避開 nanoFramework byte[] 存取比存標存取慢的大弱點。

當成演算法練習,搞了半天生出以下這段程式:

public byte[] FlipImageArray(byte[] bitmap, int w, int h)
{
    var totalRowBytes = h / 8 + (h % 8 > 0 ? 1 : 0);
    var data = new byte[w * totalRowBytes];
    var rowByteCount = w / 8 + (w % 8 > 0 ? 1 : 0);
    for (int i = 0; i < w; i++)
    {
        for (int j = 0; j < h; j++)
        {
            if ((bitmap[j * rowByteCount + i / 8] << (i % 8) & 0x80) > 0)
            {
                var offset = j / 8 * w;
                data[offset + i] |= (byte)(1 << j % 8);
            }
        }
    }
    return data;
}

public void DrawFlipArrayImage(int x, int y, int w, int h, byte[] flipBitmap)
{
    var baseIdx = y / 8 * WIDTH;
    var yOffset = y % 8;
    var hCount = h / 8 + (h % 8 > 0 ? 1 : 0);
    for (int i = 0; i < w; i++)
    {
        if (x + i > WIDTH - 1) continue;
        for (var j = 0; j < hCount; j++)
        {
            var pos = baseIdx + j * WIDTH + x + i;
            if (pos > buffer.Length - 1) continue;
            //取出 8bit 資料
            var d = flipBitmap[j * w + i];
            //最後一列可能有效範圍不足 8bit,把無效範圍遮掉
            if (j == hCount - 1)
                d &= (byte)(0xff >> (8 - h % 8) % 8);
            //寫入本列
            buffer[pos] = (byte)(buffer[pos] & 0xff >> 8 - yOffset | (d << yOffset & 0xff));
            //寫入下一列
            pos += WIDTH;
            if (pos > buffer.Length - 1) continue;
            buffer[pos] = (byte)(buffer[pos] & (0xff << yOffset & 0xff) | d >> 8 - yOffset);
        }
    }
}

令人興奮的時刻,執行時間由 41 秒推進到 18 秒!

實測比較

雖然還是比 Arduino C++ 多一倍,這個速度我已可接受,但別想妄想用 nanoFramework 播動畫,寫掌上型遊樂器就是了,都讓你用 C# 了,別太貪心。哈!

Experience of tuning naneFramework code for SSD1306 OLED display.


Comments

Be the first to post a comment

Post a comment