HN 日本語サマリー

← 一覧へ戻る
プログラミング

マイクロコントローラー向けJITビットマップテキストレンダリング

JIT bitmap text rendering for microcontrollers (linebender.org)

91 pointsby dmit17 コメント

要約

RP2350のようなマイクロコントローラー上で、ビットマップフォントを用いた高速なテキストレンダリングを実現する技術が紹介されています。この手法は、実行時に最適化されたARMアセンブリコードを生成するJIT(Just-In-Time)コンパイラを利用し、限られたリソースで高パフォーマンスを達成することを目指しています。

全文翻訳

マイクロコントローラー向けJITビットマップテキストレンダリング

Raph Levien, 2026年11月7日

私はvic-2350に取り組んでいます。これは80年代のホームコンピューターをRP2350マイクロコントローラーボード上で再構築するプロジェクトです。もちろん、このようなプロジェクトにはテキストレンダリングが必要です。ビットマップフォントは現在あまり使われていませんが、効率性と、低解像度ではアンチエイリアスフォントレンダリングのぼやけた塊よりも、シャープで意図的にデザインされたビットマップフォントの美学を好むため、これらを選択しました。それらは、狭いながらもニッチな存在です。

また、プロポーショナルスペーシングとカーニングも欲しかったのですが、これも初期のマッキントッシュとその前身の美学を愛しているからです。RP2350プロジェクトでのほとんどのテキストレンダリングは固定文字セルに基づいています。これは、良好なパフォーマンスを達成するのが難しいからです。

[画像: RP2350上でのビットマップテキストレンダリングのデモ]

この記事では、RP2350のようなマイクロコントローラー上でビットマップテキストを非常に高速にレンダリングする技術を紹介します。私はこれをレース・ザ・ビームレンダラーの一部として実装しましたが、フレームバッファに書き込む場合でも同じ技術が機能します。中心的なアイデアは、文字列レンダリングのためにオンザフライで高度に最適化されたコードを生成するJITです。

レース・ザ・ビーム

80年代半ば頃、コンピューターでのテキスト表示方法に分裂が起こっていました。確立された技術は固定セルサイズであり、画像がモニターにスキャンアウトされる際にフォントROMからオンザフライで文字をルックアップするハードウェアでした。利点は、限られたハードウェアで非常に高速な更新と応答性でした。多くのターミナルやホームコンピューターがこの方法で動作していました。欠点は柔軟性の欠如でした。特に、モノスペースフォントしかサポートされていませんでした。

新しい技術はビットマップフレームバッファであり、これはXerox Altoに始まる一連のコンピューターで最初に採用され、Apple Lisaを経て、最終的にマッキントッシュで普及しました。フレームバッファは非常に柔軟でしたが、更新するにはより多くのコンピューティングパワーが必要でした。80年代初頭には、テキストが徐々にレンダリングされるのを見るのが一般的でした。ホームコンピューターではグラフィックスモードも一般的に利用可能でしたが、通常はテキストモードよりも解像度が低く、1MHzの6502(または同等品)では十分なパワーがなかったため、ワードプロセッサやスプレッドシートのような本格的なテキストベースのプログラムはテキストモードを使用していました。

RP2350に話を戻しましょう。HSTXペリフェラルの助けを借りてDVIビデオ出力を可能にしますが、ビデオバッファを埋めるのは完全にソフトウェア次第です。いくつかのプロジェクトではフレームバッファを選択していますが、チップには520kBのSRAMしかありません。そのため、フレームバッファは解像度またはビット深度のいずれか、あるいは両方で制限される必要があり、そのRAMをアプリが他の目的で使用できることも望ましいです。私は代わりにレース・ザ・ビームを選択し、スキャンラインごとにビデオバッファを埋めています。これにより、より豊かな視覚表現と大幅に低いレイテンシが可能になりますが、レンダラーには厳しいパフォーマンス制約がかかります。ピクセルあたり約6サイクルの予算があります。予算を下回るほど、他のグラフィカルオブジェクトや他のアプリ機能のレンダリングに利用できるものが増えます。

Rustではパフォーマンス要件を満たせないため、ARMアセンブリ言語を書き始めました。レンダラー、そしてvic-2350の大部分は、私が作成した非常に高性能なCortex-M33プログラミング言語であるsymbolasmで書かれています。JITを採用することで、レンダリングがさらに高速化されます。

レンダリングパイプライン

レンダリングはしばしばパイプラインとしてモデル化され、vic-2350レンダラーも例外ではありません。アプリケーションとレンダラーのインターフェースはディスプレイリストです。基本的に、それはその名の通り、ディスプレイオブジェクトのリストです。ディスプレイリストはダブルバッファリングされており、一方はレンダリングおよびスキャンアウト中で、もう一方はアプリケーションによって構築されます。各vsyncで、アプリの準備ができている場合、ディスプレイリストはスワップされます。ディスプレイリストの構築は計算集約的ではありませんが、もちろんアプリは他のことをするのに忙しいかもしれません。

ディスプレイリストのサイズはコンパイル時に不明なため、アロケーションが発生します。ディスプレイオブジェクトは2つのベクトルにパックされます。一方はディスプレイオブジェクト自体を保持し、もう一方は最初のベクトルへのインデックスのリストを保持します。ディスプレイオブジェクトは、vtable、境界矩形、およびオブジェクト固有のペイロードで構成されます。ディスプレイリストの構築はタイミングクリティカルではないため、アロケーションは許容されます。レンダリングは別の話です。とはいえ、特に2つのコピーがあるため、ディスプレイリストがメモリ効率が良いことは重要です。

プレゼンテーションの前に、インデックスは昇順のy0順にソートされます。

レンダリング中、レンダラーはアクティブリストを維持します。これは、現在のスキャンラインと交差するディスプレイオブジェクトのサブセットにすぎません。各スキャンラインで、y1 = y のオブジェクトはアクティブリストから削除され、y0 = y の新しいオブジェクトが挿入されます。これが前のソートの理由です。これはオブジェクトがアクティブリストに挿入される順序と正確に一致します。アクティブリストはディスプレイオブジェクト順に保持されます。

その後、レンダラーはアクティブリストの各ディスプレイオブジェクトのrenderメソッドを呼び出すだけです。まもなく、不透明なスパンを追跡し、後で上書きされるピクセル範囲のレンダリング作業を省略するなど、さらに最適化を行いますが、ここで提示するテキストレンダリングの基本ではありません。

オブジェクトの開始と終了時(挿入および削除時)に、レンダラーはディスプレイオブジェクトのstartおよびendメソッドを呼び出します。これらのメソッドはスクラッチスペースを割り当てたり、オブジェクトのレンダリングを設定するための他の作業を行ったりできます。特殊なビットマップアロケーターがあり、これは高速で決定的です。

テキストディスプレイオブジェクト

これらすべてにより、ディスプレイオブジェクトのインスタンスとしてテキストレンダリングを実装するために必要なものがすべて揃います。

テキストオブジェクト自体には、文字列(文字あたり1バイト)、フォントへのポインタ、およびフォアグラウンドとバックグラウンドの色が含まれます。RAMは貴重であり、ディスプレイリストはダブルバッファリングされるため、さらに貴重です。レンダラーの以前のイテレーションでは、ディスプレイリストに展開されたテキストバージョンがありましたが、画面がテキストでいっぱいになると許容できないメモリ圧力を引き起こしました。

startメソッドは、JITコードと1ピクセルあたり1ビットの中間バッファの両方のスクラッチスペースを割り当て、JITコンパイラを呼び出して文字列を展開してコードにします。

renderメソッドはそのコードを呼び出し、1ピクセルあたり1ビットのバッファを埋め、次に2番目のパスを実行して、その1ピクセルあたり1ビットのバッファを16ビットピクセルRGB(565形式)に展開します。この2番目のパスは、ピクセルあたり1.75サイクルの持続的なレートで実行されます。

フォントは非常にパックされた形式で保存されます。グリフのすべての「インク」は、フォントと同じ高さの広いビットマップ画像に保存されます。パッキングは、どのグリフも32ピクセルチャンク境界をまたがないように保証するため、フォントピクセルの1行は常に単一の32ビットロードで読み取ることができます。グリフの周囲にスペースがある場合、それはパックされたビットマップには保存されません。むしろ、スペースはグリフごとのメタデータにあります。

マイクロ最適化の精神で、パッキングアルゴリズムは重複も検出します。ピクセルが単一のグリフに固有である必要はありません。この最適化により、フォントビットマップのサイズが1140バイトから960バイトに削減されます。バイトはすべて重要であり、フォントを追加するにつれてさらに重要になります。フォントビットマップはSRAMに配置する必要があります。フラッシュにあると、ビデオレートでのレンダリング中にストールが発生する可能性があるためです。

[画像: 重複を含む、32ビットワードに非常に密にパックされたビットマップフォント。ワード境界は交互に青と黄色のシェーディングで示され、重複はわずかに暗いシェーディングです]

特に、{][}シーケンス(2行目の開始近く)が好きでした。他のグリフに含まれるグリフは個別にパックする必要はありません。いくつかの例があり、驚くべきものもあります。たとえば、lはUの左側であり、0はスペースを削除した()と同一です。

グリフごとのメタデータ(カーニングを除く)は、概念的には4つのフィールドです:ビットマップ内のインクのx位置と幅、左側のベアリング、およびアドバンス。これらすべてが32ビット整数に密にパックされています。

JIT

生成されたJITコードでは、レジスタは次のように割り当てられます:

  • r0はパックされたフォントビットマップの行の開始を指します
  • r1は出力バッファ、1ビット/ピクセルの1行画像バッファです
  • r2は出力ビットマップのアキュムレータです
  • r3はr4から抽出された1つのグリフのビットを保持します
  • r4はグリフビットマップからロードされた32ビットを保持します

最も単純なケースでは、アキュムレータに1つのグリフを追加します。コードは次のようになります:

    ldr r4, [r0, #(x_offset / 32 * 4)]
    ubfx r3, r4, #(x_offset % 32), #(width)
    orr r2, r3, lsl #(x % 32)

単語が完了したときに出力する追加コード(stmia r1!, {r2})や、グリフが32ビット境界をまたぐ場合を処理するコードがあります。これらはtext.syで文書化されています。しかし、ここで言及したいトリックが1つあります:上記のコードはldrとubfx(符号なしビットフィールド抽出)の間に結果使用遅延があります。これは、命令の順序をいくらかシャッフルすることで解決されます。グリフiの最後の命令は、グリフi + 1の最初の命令(ldr)の後に再配置され、遅延スロットを埋め、サイクルコストを4から3に削減します。

これは、アキュムレータをまたがないグリフのみに焦点を当てたコードジェネレータの内部ループです。各グリフについて、いくつかの値がg変数にパックされ、その後以下で展開されます:パックされたフォントビットマップ内のオフセット(ビットオフセットとワードオフセットにさらに分解されます)、インク部分の幅、左側ベアリング、およびアドバンス。

    loop {
        g @r12 = *(glyphs + c * 4)
        // 次の文字、末尾を1つ超えて読み取る
        c = *text++
        width @r7 = g[20..26]
        bit_offset @r4 = g & 31
        if width == 0 goto blank
        // ubfx r3, r4, #offset % 32, #width
        ubfx_instr @r11 = *(ubfx_table + bit_offset * 4)
        // ldr r4, [r0, #offset / 32]; フォントは32ワードより狭いため、
        // オフセットは収まり、strhはそれより上のものを除外します。
        word_offset @r4 = g >> 5
        ldr @r4 = ldr_base + (word_offset << 6)
        ubfx_instr += width << 16
        *slot = ldr
        // 左側ベアリング
        left @r4 = (g as i32)[16..20]
        *(code as *u32) = ubfx_instr
        // シフト:グリフがr2のワードに対してどこから始まるか。ブランチも
        // 取られるのは負の場合です。
        shift @r4 = pen + left
        room @r7 = 32 - width
        pen += g >> 26
        #(shift - room)
        if #hi goto off_word
        // 遅延:orr r2, r2, r3, lsl #shift
        d @r12 = *(orr_table + shift * 4)
        *((code + 6) as *u32) = d
        slot = code + 4
        code += 10
        #(n -= 1)
        if #ne goto loop
    }

対応するマシンコード、ARMアセンブリコード、およびsymbolasm分析ツールのサイクルカウント分析を以下に示します:

  134: f859 c028           1 ldr r12, [r9, r8, lsl #2] # グリフオフセットをロード
  136: f811 8b01           1 ldrb r8, [r1], #1       # 次の文字を読み取る
  137: f3cc 5705           1 ubfx r7, r12, #20, #6   # 幅を抽出
  138: f00c 041f           1 and r4, r12, #31        # ビットオフセットを計算
  139: b387              1/2 cbz r7, .Ljit_text.blank # 幅が0ならスキップ
  141: f855 b024           1 ldr r11, [r5, r4, lsl #2] # フォントビットマップからワードをロード
  144: ea4f 145c H         1 lsr r4, r12, #5         # ワードオフセットを計算
  145: eb0a 1484           1 add r4, r10, r4, lsl #6 # フォントベースアドレスに加算
  146: eb0b 4b07           1 add r11, r11, r7, lsl #16 # 幅とシフトを適用
  147: 801c                1 strh r4, [r3]           # 結果をバッファに格納
  149: f34c 4403           1 sbfx r4, r12, #16, #4   # 左側ベアリングを抽出
  150: f8c0 b000 H         1 str r11, [r0]           # 生成されたコードを格納
  153: 1934                1 adds r4, r6, r4         # ペン位置と左側ベアリングを加算
  154: f1c7 0720           1 rsb r7, r7, #32         # 残りのスペースを計算
  155: eb06 669c           1 add r6, r6, r12, lsr #26 # ペン位置を更新
  156: 42bc                1 cmp r4, r7              # スペースと残りのスペースを比較
  157: d821        K     0/2 bhi .Ljit_text.off_word # ワードをまたぐ場合はスキップ
  159: f85e c024           1 ldr r12, [lr, r4, lsl #2] # 遅延スロット命令をロード
  160: f8c0 c006           1 str r12, [r0, #6]       # 遅延スロット命令を格納
  161: 1d03                1 adds r3, r0, #4         # コードポインタを進める
  162: 300a                1 adds r0, r0, #10        # コードポインタを進める
  163: 3a01                1 subs r2, r2, #1         # 文字数を減らす
  164: d1da        K     0/2 bne .Ljit_text_0_loop # ループを続ける
   ループ 134-164: イテレーションあたり23サイクル

Kフラグは、条件がブランチの1サイクル前にしかわからないため、ブランチの可能性のある遅延を示します。ここでは、0/2はブランチが取られない場合は0サイクル、取られる場合は2サイクルを示します。これらはどちらも最適化できません。Hフラグは、命令が「ハイ」レジスタ(r8-r15)の使用により32ビットである(16ビットではなく)ことを示します。これらはLSPでも利用可能です。

パフォーマンス

基本的なループは、JITあたりグリフあたり23サイクル、ビットマップ生成あたり高さあたり3サイクルです。出力幅32ビットあたりJITと高さあたり2サイクル追加のコストが約20サイクルかかります。

これは驚くほど高速です。vic-2350のtextdemoは、JITに約21kサイクル、JITコードの実行に42kサイクルかかります。合計で、RP2350の単一コアで利用可能なスループットの約2.6%になります。565への展開は実際には約3倍高価です。したがって、可能な限り最適化されていると言っても過言ではありません。

これはレース・ザ・ビームレンダリングのために設計されましたが、フレームバッファ、特に1ビット/ピクセルのフレームバッファに書き込む場合でも同様にうまく機能します。コードを生成し、各スキャンラインで実行するだけです。

JIT出力をキャッシュしたくなりますが、RAMの節約がより重要です。JIT出力は、入力文字列に対して約10倍の展開になります。そして、上記の数値から、JITを実行するコストは最小限であることが明らかです。

「ジャストインタイム」という名前は的確です。vic-2350の実装では、JITコンパイラはビデオラインのスキャンアウトの100µs未満で実行されます。

直接的なパフォーマンス比較を既存のRP2350テキストレンダラーで見つけることはできませんでした。おそらく最も近いのは、Michael Bellによるdvhstxで、Adafruitによってフォークされました。これもピクセルあたり合計約2〜2.5サイクルを費やしますが、14x24グリッドに限定されており、RGB 222モードを使用するため、出力帯域幅は半分しかありません(そして、これが時間の大部分を占めます)。

ドット積カーニング

カーニングはテキストレンダリングの非常に重要な洗練です。そうでなければ、スペーシングが不規則に見えます。OpenTypeフォントの一般的なメカニズムは非常に複雑です。このプロジェクトでは、シンプルで効率的なものを求めていたため、もともとGPUでテキストレイアウトを行おうとしていたアイデアを復活させました。

基本的に、各グリフには2つのビットベクトルがあり、それぞれ左側と右側用です。2つのグリフをカーニングするには、最初のグリフの右側ビットベクトルと2番目のグリフの左側ビットベクトルのandを取り、そのポップカウント(1ビットの数)を計算して、カーニングするピクセル数を取得します。これは実際には2つのベクトルのドット積であり、ビットは0と1として解釈されます。カーンテーブルはグリフあたり2バイトしか必要とせず、カーニングの評価は非常に高速です。

それでも、カーニングの評価はレース・ザ・ビーム時ではなく、ディスプレイリスト構築時に行われます。カーンがゼロでない場合、テキストディスプレイオブジェクトの文字列に特別な負のスペース文字が挿入されます。

このフォントのために、カーニングテーブルを手動で作成しました。各ビットは特定の機能を表します。たとえば、ビット0は最初のグリフの右上部分が突き出ていることを表し、次のグリフの左上部分にインクがないことを表します。したがって、TとYの右側、およびほとんどの小文字とAの左側に設定されています。カーニング情報を準備された他の方法で蒸留するのに役立つツールを想像するのは難しくありません。

まだ実際の実装は行っていませんが、このアイデアは実際のフォントにもスケールする可能性があると推測しています。ビットと整数のカーニング量ではなく、ベクトルは連続値(適度に量子化された)を格納します。乗算後、合計前の追加の非線形ステップも役立つかもしれません。ドット積は、もちろんGPUで計算するのが非常に効率的ですが、OpenTypeテーブルを介したインデックス追跡はそうではありません。しかし、その探求は別の機会に譲ります。

カーニングは品質を大幅に向上させます:

[画像: カーニングの比較、最初の行はカーニングなし、2番目の行はカーニングあり]

AI開示

この作業は、pico-dvi-rsのテキストレンダリングの改良であり、これは手書きでした。JITコアのドラフトはRustで手書きし、その後Claudeを使用してsymbolasmに翻訳し、洗練・最適化しました。このブログは手書きです。

symbolasmコンパイラと分析ツールはLLMによって大幅に支援されていますが、これも初期の手書きバージョンから進化しました。

マイクロタイポグラフィの実験を行いましたが、それはかなりクールな結果でしたが、含めないことにしました。これは、LLMコンテンツの割合が増加するためでもあります。

業界の他の多くの人々と同じように、品質とLLMが提供するアクセラレーションのバランスに苦労しています。Claudeは低レベルのコーディングタスクにおいて非常に優れており、JITコンパイラのドラフトでエッジケースを簡単に見つけました。賢明に使用すれば、LLMは品質を向上させることができると私は考えており、そのために努力しています。

JITに特化したテキストレンダリングの先例はあまり見つけられませんでした。もちろん、JIT全般に関する膨大な文献があります。オンザフライのコード生成は、1982年頃のBlitのために行われました。詳細については、Bitmap Graphics SIGGRAPH 84を参照してください。興味深い詳細は、彼らが毎秒約2500文字を主張していることです。RP2350ハードウェアでのこの生のパフォーマンスは、1bppへの出力のみをカウントしても(Blitのフレームバッファが構成されていた方法)、3桁近く大きいです。