しまねソフト研究開発センター(略称 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ビット倍精度の場合以下の通りのフォーマットになっています。
また、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!
