3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

mruby/c で NaN boxing をサポートしメモリ使用量を減らしました

Last updated at Posted at 2025-12-01

しまねソフト研究開発センター(略称 ITOC)にいます、東です。
この記事は、mrubyファミリ (組み込み向け軽量Ruby) Advent Calendar 2025 の第1日目の記事です。

先日 9月24日、環境限定ながら mruby/c に NaN boxing の実装を入れました。今回は、その設計と実装、得られた効果などについて説明します。

NaN boxing について

低レイヤーの技術で、あまり知られていないようですので、最初に NaN boxing について簡単に説明します。

昨今の計算機では、ほとんどの場合浮動小数点型のデータを IEEE754 フォーマットで表します。例えば64ビット(倍精度)フォーマットの場合、数値を、

  • 1ビットの符号(s)
  • 52ビットの仮数(f)
  • 11ビットの指数(e)

で表し、値を $f × 2^e$ で、正負を s ビットで表すといった方式です。(実際にはもう少し工夫されています)
そして、指数部 e のビットがすべて 1 の場合には、特別な意味が与えられています。

指数部 (e) 仮数部 (f) 意味
0b111_1111_1111 0 無限大
0b111_1111_1111 0以外 NaN (Not a Number)

ここで、NaN の仮数部(f)が、「0以外」となっているのがポイントで、0以外なら何でも良い、……ということは

  • 0 以外の値にするため、どこか 1ビットを常に 1 にする
  • すると残りの 51ビットは、自由に使うことができる

と読み替えることができ、51ビット分のデータコンテナが手に入ります。

設計思想

設計に当たり、以下のことを心がけました。

  • ビットフィールドを使わない(C言語実装依存を避ける)
  • データを 8bit 単位として、実装依存の回避と実行速度の向上を狙う
  • 既存のコードを、可能な限り壊さない

これを実現するためには、ある程度実行環境を制限する必要があります。

前提条件とした環境

  • 32bit アーキテクチャ (ポインタが32bit)
  • double が、IEEE754 binary64(bit) フォーマット
  • Little Endian (当初)

後ほど記述しますが、データタイプ .tt を直接参照している既存コードは、最終的にどうしても修正が必要でした。

現在の mruby/c の実装

Rubyのように変数に型がない言語の場合、

  • その変数に何が入っているかを表すデータタイプ(mrbc_vtype tt)
  • 実際のデータ(int32_t i など)

という2種類の情報が必要です。
mruby/c の場合、以下のように構造体を定義して、それを表しています。

mrbc_value の構造(抜粋版、説明のため一部変更)

typedef struct RObject {
  mrbc_vtype tt;

  union {
    int32_t i;                  // MRBC_TT_INTEGER
    double d;                   // MRBC_TT_FLOAT
    uint16_t sym_id;            // MRBC_TT_SYMBOL
    struct RClass *cls;         // MRBC_TT_CLASS, MRBC_TT_MODULE
    struct RInstance *instance; // MRBC_TT_OBJECT
    struct RProc *proc;         // MRBC_TT_PROC
    struct RArray *array;       // MRBC_TT_ARRAY
    struct RString *string;     // MRBC_TT_STRING
    struct RRange *range;       // MRBC_TT_RANGE
    struct RHash *hash;         // MRBC_TT_HASH
    struct RException *exception; // MRBC_TT_EXCEPTION
  };
} mrbc_value;

mrbc_vtype は整数値で、以下のように定義されています。

typedef enum {
  MRBC_TT_NIL       = 1,        //!< NilClass
  MRBC_TT_FALSE     = 2,        //!< FalseClass
  MRBC_TT_TRUE      = 3,        //!< TrueClass
  MRBC_TT_INTEGER   = 4,        //!< Integer
  MRBC_TT_FIXNUM    = 4,
  MRBC_TT_FLOAT     = 5,        //!< Float
  MRBC_TT_SYMBOL    = 6,        //!< Symbol
  MRBC_TT_CLASS     = 7,        //!< Class
  MRBC_TT_MODULE    = 8,        //!< Module
  MRBC_TT_OBJECT    = 9,        //!< General instance
  MRBC_TT_PROC      = 10,       //!< Proc
  MRBC_TT_ARRAY     = 11,       //!< Array
  MRBC_TT_STRING    = 12,       //!< String
  MRBC_TT_RANGE     = 13,       //!< Range
  MRBC_TT_HASH      = 14,       //!< Hash
  MRBC_TT_EXCEPTION = 15,       //!< Exception
} mrbc_vtype;

これは、標準的なARM32用コンパイラを使った場合において、一つの値に最低でも

tt(4bytes) + pad(4bytes) + union(double 8bytes) = 16bytes

必要ということです。
NaN boxing を使うと、double 一つ分、つまり半分の 8bytes にすることができます。だいぶ省メモリになりそうですよね。

浮動小数点バイナリフォーマット

IEEE754 規格の調査

設計に当たり、まず IEEE754の詳細について調査します。本来ならIEEEから規格書を購入するのが最も正確ではありますが、広く普及している規格のため、Wikipedia をはじめいくらでも詳細に解説しているサイトがあり、それらを参照するだけでも十分です。

Wikipedia該当ページ によると、64ビット倍精度の場合以下の通りのフォーマットになっています。

2025-12-01 12.30.37.png
(Wikipedia該当ページより引用)

また、IEEE754 ではエンディアンに関する規定は無いので、実装ではそのあたりも注意すべき点になります。

実機の挙動の調査

実機では NaN がどのようなビットパターンになっているか、代表的な2種類のCPUで調査してみます。

調査用プログラム

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <math.h>

typedef union IEEE754 {
  double d;
  struct {
    uint64_t f1 : 32;
    uint64_t f2 : 20;
    uint16_t e : 11;
    uint8_t  s : 1;
  };
} IEEE754;

int main() {
  IEEE754 d;

  d = (IEEE754){.d = NAN};
  printf("%f = s:%x e:%03x f:%05x%08x\n", d.d, d.s, d.e, d.f2, d.f1);

  d = (IEEE754){.d = 0.0/0.0};
  printf("%f = s:%x e:%03x f:%05x%08x\n", d.d, d.s, d.e, d.f2, d.f1);

  return 0;
}

C言語で NaN値を作る方法としては、math.h に定義されている NAN定数を使う方法と、無効な計算をする方法がよく使われます。正確には、0.0/0.0 が必ずしもNaNになるかどうかは規定されていないと思います(未調査)ですが、慣習的に使われます。

ARM32

ラズパイを使いARM32ビット機について調査します。
使ったコンパイラは、gcc version 10.2.1 20210110 (Raspbian 10.2.1-6+rpi1) です。

実行結果

nan = s:0 e:7ff f:8000000000000
nan = s:0 e:7ff f:8000000000000

NAN定数を使う方法も無効な計算をする方法も、どちらも同じパターンで、仮数部(f)は最上位ビットのみ1になっています。

x86-32

Debian12 を仮想マシン上で動かした環境を調査します。
コンパイラは、gcc version 12.2.0 (Debian 12.2.0-14+deb12u1) です。

実行結果

nan = s:0 e:7ff f:8000000000000
-nan = s:1 e:7ff f:8000000000000

ARMと違い、2つの方法でサインビットが反転していますが、仮数部(f)はやはり最上位ビットのみ1になっています。

この調査結果より、仮数部の最上位ビットを1にしておけば、ほとんどの場合でNaNと判定されるだろうと予想し、設計に反映します。

mruby の実装調査

mrubyでは、既に NaN boxing が実装されていますので、そちらも参考にします。ありがたいことに、ドキュメント doc/internal/boxing.md が提供されており、その中に詳細なビットパターンが記されています。
それによると、ほとんどのOSでアドレス幅は48bitまでである事を利用し、64bit機でも NaN boxing を使えるように工夫されていたり、アドレスの最下位ビットを使ってデータタイプを判定していたり、複雑な処理を行っています。
当初は、この戦略をそっくり真似ることを考えていたのですが、mruby/c が主に使われる環境(16 or 32bitマイコン)を考えると、もう少し簡易かつ実行速度に考慮した戦略がよいと考え、独自に設計することにしました。

実装開始

では、実際に設計し、実装していきます。

ビットパターンと構造体

まずは、ビットパターンを定義し、それをアクセスする構造体を実装します。
当初目標通り、8bitを単位として以下の通り設計しました。

Bit field of:
 IEEE754 binary64
  SEEEEEEE EEEEFFFF  FFFFFFFF FFFFFFFF   FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF
  b7*      b6*       b5*      b4*        b3*      b2*      b1*      b0*

 mrbc_value
  11111111 11111111  00000000 TTTTTTTT   VVVVVVVV VVVVVVVV VVVVVVVV VVVVVVVV
  ~~~~~~~~~~~~~~nan  ~~~~~pad ~~~~~~tt
  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~tt2
  • 上位16bit (b7,b6) がすべて1(signed でマイナス1と判断できる)であれば、NaN boxing されたデータである
  • 浮動小数点データの場合は、上位16bitがすべて1になることは、規格上ない
  • データタイプ tt は、8bit幅で良いので、pad をいれて 16bitにする。順番はどちらでも大差ないと思うが、ここでは b5:pad, b4:tt にした
  • 下位32bit (b3,b2,b1,b0) は、数値もしくはポインタ
  • nan および tt を一つの32ビット値だけで指定することができるように、tt2 というメンバ名も用意する

これを表すC言語構造体はこうです。

typedef struct RObject {
  union {
    struct {
      // LSB 32bit
      union {
        int32_t i;                      // MRBC_TT_INTEGER
        mrbc_sym sym_id;                // MRBC_TT_SYMBOL
        struct RClass *cls;             // MRBC_TT_CLASS, MRBC_TT_MODULE
        struct RInstance *instance;     // MRBC_TT_OBJECT
        struct RProc *proc;             // MRBC_TT_PROC
        struct RArray *array;           // MRBC_TT_ARRAY
        struct RString *string;         // MRBC_TT_STRING
        struct RRange *range;           // MRBC_TT_RANGE
        struct RHash *hash;             // MRBC_TT_HASH
        struct RException *exception;   // MRBC_TT_EXCEPTION
      };

      // MSB 32bit
      union {
        struct {
          int8_t   tt;  // valid if nan==0xffff
          uint8_t  pad;
          uint16_t nan;
        };
        uint32_t tt2;
      };
    };

    double d;                           // MRBC_TT_FLOAT
  };
} mrbc_value;

ネストが深い struct/union ができあがりました。また、無名 union を使っていますが、今どきこれが使えないコンパイラを見たことがない1ので、ソースの可読性の方を重視し、mruby/c ではたくさん使っています。

アクセス用マクロの実装

mrbc_value 構造体のアクセス用マクロが、もともといくつかありました。それらを書き換える必要があります。

たとえば、データタイプを得るマクロは、もともと以下のとおりとても簡単なものでした。

#define mrbc_type(o)  ((o).tt)

これを、以下のように書き換えます。

#define MRBC_NAN_BITS 0xFFFF
#define mrbc_type(v)  ((v).nan == MRBC_NAN_BITS ? (v).tt : MRBC_TT_FLOAT)

ここで、NaN 判定に16ビットも使った効果が発揮されます。ハーフワードアライメントされた 16ビットデータがマイナス1であることの判定は、多くのCPUにとってビット演算を行わずアドレス演算だけでデータの準備ができ、マイナス1という値は、全ビットが立っているかもしくはゼロから1を引いた値と比較という、非常に軽い処理として実行可能です。

次に構造体に tt2 を用意した意味を説明します。
以下に示すのは、Integer 型の即値をセットするインライン関数です。

// NaN boxing を使わない場合
static inline void mrbc_set_integer(mrbc_value *p, mrbc_int_t n)
{
  p->tt = MRBC_TT_INTEGER;
  p->i = n;
}

// NaN boxing を使う場合
static inline void mrbc_set_integer(mrbc_value *p, mrbc_int_t n)
{
  p->tt2 = MRBC_NAN_BITS << 16 | MRBC_TT_INTEGER;
  p->i = n;
}

NaN boxing の使用の有無にかかわらず、ほぼ同じコストで実行できることが予想できます。とはいえ、nan, tt に別々に値をセットしたとしても、最適化が効けば同じコードがでるような気もします(未確認)が、さほど可読性も悪くならないし、ソースコードから実行コストが予測できることはありがたいので、このようにしてみました。

性能評価

速度およびメモリ消費量のベンチマークテストを行いました。

テストは、ハノイの塔の解法プログラム(円盤8枚 × 10回)と、マージソート(n = 200 × 10回)です。

速度

ARM32 (STM32F401RE Cortex-M4)

基準 (ms) NaN boxing %
hanoi 2,472 2,439 -33 -1.3
merge sort 2,637 2,577 -60 -2.3

x86-32 (AMD Geode LX800)

基準 (ms) NaN boxing %
hanoi 125 129 4 3.2
merge sort 108 112 4 3.7

実行速度は、数パーセント程度 ARM で速く、x86 で遅くなる現象が観測できました。x86は、OS上で動作しているので多少のジッターがでます。数回測定し、おおよその平均を記載しました。
両方ともわずかに遅くなる予想をしていたのですが、ARMに関しては速くなりました。データ構造を工夫し、ビット演算を行わなくても良いように設計した効果があったのだと思います。

メモリ消費量 (RAM)

ARM32 (STM32F401RE Cortex-M4)

基準 (bytes) NaN boxing %
hanoi 6,396 4,824 -1,572 -24.6
merge sort 17,728 10,156 -7,572 -42.7

x86-32 (AMD Geode LX800)

基準 (bytes) NaN boxing %
hanoi 5,436 4,668 -768 -14.1
merge sort 13,760 9,980 -3,780 -27.5

RAM使用量は、目論見通り最大で42.7%ダウンと、大きな効果がありました。
ARMよりもx86の効果が少ないのは、構造体のパディングのルールが違うからです。双方ともgccですが、ARMは64bit単位、x86は32bit単位でパディングされるようですね。2

メモリ消費量 (ROM)

ARM32 (STM32F401RE Cortex-M4)

基準 (bytes) NaN boxing %
----- 89,388 94,620 5,232 5.9

x86-32 (AMD Geode LX800)

基準 (bytes) NaN boxing %
----- 118,692 123,732 5,040 4.2

ROM (Flash) の使用量は、いずれも増加傾向です。

利用者側の注意点

デフォルトでは、NaN boxing を使いません。vm_config.h へ MRBC_NAN_BOXING を定義することで、NaN boxing を使うようにコンパイルします。

#define MRBC_NAN_BOXING

Integer型が32bit以上に指定している場合 (MRBC_INT64) と NaN boxing は、併用できません。

mrbc_value のメンバ名に直接アクセスできない場合がある

C言語で拡張関数や独自クラスを書く場合に、どうしてもいくつかの制約(非互換性)が発生してしまいました。特に、データタイプ tt を判定する場合です。
既存コードには、多くそのようなコードが含まれていました。

  mrbc_value val;
  ...
  if( val.tt == MRBC_TT_NIL ) { ... }

これらは、アクセスマクロで wrap する必要があります。

  if( mrbc_type(val) == MRBC_TT_NIL ) { ... }

同様に、tt をセットする場合も、mrbc_set_tt() を使い wrap する必要があります。

  mrbc_set_tt( &val, MRBC_TT_INTEGER );

mrbc_value へのタイプセットと値セットの順番依存性

こちらはあまり問題になるケースは考えにくいのですが、可能性として記述しておきます。

mrbc_value にデータタイプ tt をセットし、値をセットするという2段階を踏む場合、Float の場合だけはこれを逆順にすると問題が発生します。

  // 問題になる場合
  val.d = 数値;
  mrbc_set_tt( &val, MRBC_TT_FLOAT );

  // 問題にならない場合
  mrbc_set_tt( &val, MRBC_TT_FLOAT );
  val.d = 数値;

しかしながら、データタイプに応じたセッター( mrbc_set_integer(), mrbc_set_float() ほか)や、即値生成マクロ( mrbc_integer_value(), mrbc_float_value() ほか )が用意してあり、そちらを使えば簡単かつ問題が起こりません。

  // こう書くほうが望ましい
  mrbc_set_float( &val, 数値 );

エンディアンについて

当初リトルエンディアンしかサポートしないつもりで設計していましたが、途中データ構造がうまく設計できたのでビッグエンディアンにも対応できる可能性が見えてきました。実際、mrbc_value 構造体の書き方(メンバーの順番)を変更するだけで対応できたので、一応ビッグエンディアン対応コードは入れてあり、実機 PowerPC 405 にてテストしてあります。
しかし前述したとおり、IEEE754のメモリ上でのバイト順は規定されておらずCPU依存です。動かない環境があるかもしれません。

おわりに

mruby/c の、NaN boxing の設計と実装について説明しました。最終的に予想したほど実行速度やROM使用量に影響をあたえず、実装ができたと思います。しかしながら、使うことができる環境は機種依存(32bit 機に限られる)なので、デフォルトでは有効にしていません。うまく使える環境であれば是非有効にして、RAM利用量の低減を図ってみてください。

それでは、Happy holidays!

  1. 無名構造体・共用体は、C11からは正式に使えます。

  2. 構造体に double を含んでいるため、double境界に厳しいARM用コンパイラがそのようにしたのだと思います。

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?