以下の内容はhttps://yui-knk.hatenablog.com/entry/2025/12/23/115553より取得しました。


Ruby Parser開発日誌 (24-7) - parse.yが生成するノードを変える ー 引数を渡したい(compiler編 splatの巻)

7日目: 引数をコンパイルする

前回は引数関係のparse処理を変更して、出力されるノードを変更したのでした。 今回はそれらをコンパイルして実行できるようにしていきます。

setup_argssetup_args_core

メソッド呼び出しをコンパイルするメインの関数はcompile_callです。 そのなかでも特に引数周りの処理を担当しているのが、setup_argssetup_args_coreです。

setup_args(rb_iseq_t *iseq, LINK_ANCHOR *const args, const rb_arguments_node_t *argn,
           unsigned int *flag, struct rb_callinfo_kwarg **keywords)
{
    ...
    if (argn) {
        if (check_arg) {
            switch(nd_type(check_arg)) {
              case(NODE_SPLAT):
                // avoid caller side array allocation for f(*arg)
                dup_rest = SPLATARRAY_FALSE;
                break;
              case(NODE_ARGSCAT):
                // avoid caller side array allocation for f(1, *arg)
                dup_rest = !nd_type_p(RNODE_ARGSCAT(check_arg)->nd_head, NODE_LIST);
                break;
              case(NODE_ARGSPUSH):
                ...
                break;
              default:
                break;
            }
        }
    }
}

static int
setup_args_core(rb_iseq_t *iseq, LINK_ANCHOR *const args, const NODE *argn,
                unsigned int *dup_rest, unsigned int *flag_ptr, struct rb_callinfo_kwarg **kwarg_ptr)
{
    if (!argn) return 0;

    NODE *kwnode = NULL;

    switch (nd_type(argn)) {
      case NODE_LIST: {
        // f(x, y, z)
        ...
        return len;
      }
      case NODE_SPLAT: {
        // f(*a)
        ...
        return 1;
      }
      case NODE_ARGSCAT: {
        ...
        return argc;
      }
      case NODE_ARGSPUSH: {
        ...
        return argc;
      }
      default: {
        UNKNOWN_NODE("setup_arg", argn, Qnil);
      }
    }
}

どちらもNODE_ARGSCATNODE_ARGSPUSHといった見慣れないノードの名前が書かれています。

なんだこれ...

悲報: ノードの構造がだいぶ変わっている

ここまでいろいろな変更を入れてきましたが、その大部分においてノードの構造が変わることはありませんでした。 構造体のフィールド名が変わる、もしくはいままでリスト構造で持っていたものが配列になる、せいぜいそのくらいの変更でした。

今回取り扱う引数はノードの書き換え前後で構造が大きく変わっています。 引数周りのバイトコードを理解しないといけません...

こういうときはいくつかの点に注意しながら進んでいくのがよいでしょう

  1. いきなり複雑なことをしない。できる限り簡単な例からはじめる
  2. 実装の詳細だけに固執しない。そもそもどういうデザインになっているのか理解する努力をする
  3. 具体的な成果物(今回でいえば命令列)を常に確認・観察する
  4. 手が止まりそうになったら、なにか手を動かす方法を考える
  5. 一度に全部は理解できないのだからある程度は仮説を置いた上で進める

というわけでまずは最もシンプルな形から始めていきましょう。

引数が固定長のとき

まずは単純に引数の数を増やしたり減らしたりして、生成されるバイトコードを観察します。

# 0000 putself                                                          (   1)[Li]
# 0001 putobject_INT2FIX_1_
# 0002 opt_send_without_block                 <calldata!mid:m1, argc:1, FCALL|ARGS_SIMPLE>
# 0004 pop
m1(1)

# 0005 putself                                                          (   2)[Li]
# 0006 putobject_INT2FIX_1_
# 0007 putobject                              2
# 0009 opt_send_without_block                 <calldata!mid:m2, argc:2, FCALL|ARGS_SIMPLE>
# 0011 pop
m2(1, 2)

# 0012 putself                                                          (   3)[Li]
# 0013 putobject_INT2FIX_1_
# 0014 putobject                              2
# 0016 putobject                              3
# 0018 opt_send_without_block                 <calldata!mid:m3, argc:3, FCALL|ARGS_SIMPLE>
# 0020 leave
m3(1, 2, 3)

これらを眺めると2つのことがわかります。

  1. 引数は順番にスタックに積まれる
  2. 引数の数がargcとしてopt_send_without_block命令に渡される

CRubyのVMはスタックマシンなので、値の受け渡しをする場合にはスタックを使うことになります1。 呼び出されたメソッド側ではいくつ引数が渡ってきているかを知る必要があります2。そのためメソッドの呼び出し側で引数に関する情報を渡してあげる必要があります3

m4(true, false, nil)を例にして書き換え前後のノードを確認しておきましょう4

Before
#     @ NODE_FCALL (id: 0, line: 1, location: (1,0)-(1,20))*
#     +- nd_mid: :m4
#     +- nd_args:
#         @ NODE_LIST (id: 2, line: 1, location: (1,3)-(1,19))
#         +- as.nd_alen: 3
#         +- nd_head:
#         |   @ NODE_TRUE (id: 1, line: 1, location: (1,3)-(1,7))
#         +- nd_head:
#         |   @ NODE_FALSE (id: 3, line: 1, location: (1,9)-(1,14))
#         +- nd_head:
#         |   @ NODE_NIL (id: 5, line: 1, location: (1,16)-(1,19))
#         +- nd_next:
#             (null node)

After
    +-- @ CallNode (location: (1,1)-(20,2))*
        +-- CallNodeFlags: nil
        +-- receiver: nil
        +-- name: :m4
        +-- arguments:
        |   @ ArgumentsNode (location: (1,3)-(1,19))
        |   +-- ArgumentsNodeFlags: nil
        |   +-- arguments: (length: 3)
        |       +-- @ TrueNode (location: (1,3)-(1,7))
        |       +-- @ FalseNode (location: (1,9)-(1,14))
        |       +-- @ NilNode (location: (1,16)-(1,19))
        +-- closing_loc: nil
        +-- equal_loc: nil
        +-- block: nil

どちらのケースも引数がリストもしくは配列の形で管理されています。 setup_args_coreでいうとcase NODE_LIST:の場合が今回のm4(true, false, nil)に対応していそうです。

static int
setup_args_core(rb_iseq_t *iseq, LINK_ANCHOR *const args, const NODE *argn,
                unsigned int *dup_rest, unsigned int *flag_ptr, struct rb_callinfo_kwarg **kwarg_ptr)
{
    if (!argn) return 0;

    NODE *kwnode = NULL;

    switch (nd_type(argn)) {
      case NODE_LIST: {
        // f(x, y, z)
        int len = compile_args(iseq, args, argn, &kwnode);
        RUBY_ASSERT(flag_ptr == NULL || (*flag_ptr & VM_CALL_ARGS_SPLAT) == 0);

        if (kwnode) {
            if (compile_keyword_arg(iseq, args, kwnode, kwarg_ptr, flag_ptr)) {
                len -= 1;
            }
            else {
                if (keyword_node_single_splat_p(kwnode) && (*dup_rest & DUP_SINGLE_KW_SPLAT)) {
                    compile_single_keyword_splat_mutable(iseq, args, argn, kwnode, flag_ptr);
                }
                else {
                    compile_hash(iseq, args, kwnode, TRUE, FALSE);
                }
            }
        }

        return len;
      }

主な処理はcompile_argsという関数で行っているので、そちらをみます。

static int
compile_args(rb_iseq_t *iseq, LINK_ANCHOR *const ret, const NODE *node, NODE **kwnode_ptr)
{
    int len = 0;

    for (; node; len++, node = RNODE_LIST(node)->nd_next) {
        if (CPDEBUG > 0) {
            EXPECT_NODE("compile_args", node, NODE_LIST, -1);
        }

        if (RNODE_LIST(node)->nd_next == NULL && keyword_node_p(RNODE_LIST(node)->nd_head)) { /* last node is kwnode */
            *kwnode_ptr = RNODE_LIST(node)->nd_head;
        }
        else {
            RUBY_ASSERT(!keyword_node_p(RNODE_LIST(node)->nd_head));
            NO_CHECK(COMPILE_(ret, "array element", RNODE_LIST(node)->nd_head, FALSE));
        }
    }

    return len;
}

last node is kwnode(キーワード引数が絡むケース)をいったん無視して読むと、この関数では2つのことを行なっています。

  1. リストの要素1つ1つをコンパイルする(COMPILE_)
  2. リストの長さを計算する(len)

たしかにこれらを行えば引数をスタックに積んだ上で、opt_send_without_blockに渡すargcの計算もすることができます。

これらを踏まえてcompile_argsとその呼び出し元であるsetup_args_coreを修正します。

static int
compile_args(rb_iseq_t *iseq, LINK_ANCHOR *const ret, const rb_arguments_node_t *node, NODE **kwnode_ptr)
{
    int len = 0;
    rb_node_list2_t *list = &node->arguments;

    for (size_t i =0; i < RB_NODE_LIST_LEN(list); len++, i++) {
        if (CPDEBUG > 0) {
            EXPECT_NODE("compile_args", node, NODE_LIST, -1);
        }

        // if (RNODE_LIST(node)->nd_next == NULL && keyword_node_p(RNODE_LIST(node)->nd_head)) { /* last node is kwnode */
        //     *kwnode_ptr = RNODE_LIST(node)->nd_head;
        // }
        // else {
            // RUBY_ASSERT(!keyword_node_p(RNODE_LIST(node)->nd_head));
            NO_CHECK(COMPILE_(ret, "array element", list->nodes[i], FALSE));
        // }
    }

    return len;
}

static int
setup_args_core(rb_iseq_t *iseq, LINK_ANCHOR *const args, const rb_arguments_node_t *argn,
                unsigned int *dup_rest, unsigned int *flag_ptr, struct rb_callinfo_kwarg **kwarg_ptr)
{
    if (!argn) return 0;

    NODE *kwnode = NULL;

    {
        // f(x, y, z)
        int len = compile_args(iseq, args, argn, &kwnode);
        RUBY_ASSERT(flag_ptr == NULL || (*flag_ptr & VM_CALL_ARGS_SPLAT) == 0);

        return len;
    }

    // 書き換えが終わるまで参考にすることもあると思うので、一時的にコードを残してある
    // 絶対に上の`return len;`でこの関数を抜けるので、以下の処理には来ない
    switch (nd_type(argn)) {
      case NODE_LIST: {
     ...

そういえばキーワード引数があるときは最後がKeywordHashNodeだったので、おそらくkeyword_node_pに相当する分岐は残るだろうなというのが一瞬よぎりますが、キーワード引数のような難しいことは一度忘れて進みます。

$ ./miniruby --dump=i -e 'm4(true, false, nil)'
== disasm: #<ISeq:<main>@-e:1 (1,0)-(1,20)>
0000 putself                                                          (   1)[Li]
0001 putobject                              true
0003 putobject                              false
0005 putnil
0006 opt_send_without_block                 <calldata!mid:m4, argc:3, FCALL|ARGS_SIMPLE>
0008 leave

お、よさそうです。 これで"hello"はできるようになりました5

$ ./miniruby  -e 'p :hello'
:hello

完成ということにしていいのでは?

splat時の挙動を理解する

引数3大難しいのうちの1つ、splat(*)について考えていきましょう。 まずはバイトコードを眺めます。

# 0000 putself                                                          (   1)[Li]
# 0001 putself
# 0002 opt_send_without_block                 <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
# 0004 splatarray                             false
# 0006 opt_send_without_block                 <calldata!mid:m1, argc:1, ARGS_SPLAT|FCALL>
# 0008 pop
m1(*a)

# 0009 putself                                                          (   2)[Li]
# 0010 putobject_INT2FIX_1_
# 0011 putself
# 0012 opt_send_without_block                 <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
# 0014 splatarray                             false
# 0016 opt_send_without_block                 <calldata!mid:m2, argc:2, ARGS_SPLAT|FCALL>
# 0018 pop
m2(1, *a)

# 0019 putself                                                          (   3)[Li]
# 0020 putself
# 0021 opt_send_without_block                 <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
# 0023 splatarray                             true
# 0025 putobject_INT2FIX_1_
# 0026 pushtoarray                            1
# 0028 opt_send_without_block                 <calldata!mid:m3, argc:1, ARGS_SPLAT|ARGS_SPLAT_MUT|FCALL>
# 0030 pop
m3(*a, 1)

# 0031 putself                                                          (   4)[Li]
# 0032 putobject_INT2FIX_1_
# 0033 putself
# 0034 opt_send_without_block                 <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
# 0036 splatarray                             true
# 0038 putobject                              2
# 0040 pushtoarray                            1
# 0042 opt_send_without_block                 <calldata!mid:m4, argc:2, ARGS_SPLAT|ARGS_SPLAT_MUT|FCALL>
# 0044 pop
m4(1, *a, 2)

# 0046 putobject_INT2FIX_1_
# 0047 putself
# 0048 opt_send_without_block                 <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
# 0050 splatarray                             true
# 0052 putobject                              2
# 0054 pushtoarray                            1
# 0056 putself
# 0057 opt_send_without_block                 <calldata!mid:b, argc:0, FCALL|VCALL|ARGS_SIMPLE>
# 0059 concattoarray
# 0060 opt_send_without_block                 <calldata!mid:m5, argc:2, ARGS_SPLAT|ARGS_SPLAT_MUT|FCALL>
# 0062 leave
m5(1, *a, 2, *b)

ここから以下のようなことがわかります。

  1. いずれのケースもARGS_SPLATというフラグをopt_send_without_block命令に指定している
  2. splatarray, pushtoarray, concattoarrayといったarrayに関係する命令が使われている
  3. 見た目の引数の数とargcが一致しないケースがある(m3, m4, m5)

splatを含むメソッド呼び出しの難しさは引数の数がメソッド呼び出し時までわからないことにあります。 正確には*aの個数はaが評価できるようになった時点でわかるので、メソッド呼び出し時よりは少しだけ前にわかるのですが、すくなくともバイトコード生成時にはわかりません。

またメソッド呼び出し時には、合計で何個の引数が渡っているのかを知っておく必要があります。

ではRubyはどうしているかというと、長さが実行時までわからない部分はまとめて1つの配列にいれて引数として渡すようにしています。 m1(*a, 1)を例に考えてみましょう。

# 0000 putself                                                          (   1)[Li]
# 0001 putself
# 0002 opt_send_without_block                 <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
# 0004 splatarray                             true
# 0006 putobject_INT2FIX_1_
# 0007 pushtoarray                            1
# 0009 opt_send_without_block                 <calldata!mid:m1, argc:1, ARGS_SPLAT|ARGS_SPLAT_MUT|FCALL>
# 0011 leave
m1(*a, 1)

大雑把にいうと

  1. asplatarrayする(0004)
  2. その結果に1をpushする(0007)

という2つのことをしています。

splatarrayというのはその名に反して実際に配列を開くことはしません。 これはArray likeなaをArrayにするための命令です。

/* call to_a on array ary to splat */
DEFINE_INSN
splatarray
(VALUE flag)
(VALUE ary)
(VALUE obj)
// attr bool leaf = false; /* has rb_check_array_type() */
{
    obj = vm_splat_array(flag, ary);
}

static VALUE
vm_splat_array(VALUE flag, VALUE ary)
{
    if (NIL_P(ary)) {
        return RTEST(flag) ? rb_ary_new() : rb_cArray_empty_frozen;
    }
    VALUE tmp = rb_check_to_array(ary);
    if (NIL_P(tmp)) {
        return rb_ary_new3(1, ary);
    }
    else if (RTEST(flag)) {
        return rb_ary_dup(tmp);
    }
    else {
        return tmp;
    }
}

これらの命令を実行した結果、m1には1つの配列が引数として渡ります。 例えばa[1 ,2, 3]を返すとき、splatarrayしてpushtoarrayするとスタック上には[1, 2, 3, 1]という配列が置かれることになります。

# 命令0002(opt_send_without_block)の実行直後
[1, 2, 3]
self # レシーバー

# 命令0004(splatarray)の実行直後
[1, 2, 3]
self # レシーバー

# 命令0006(putobject_INT2FIX_1_)の実行直後
1
[1, 2, 3]
self # レシーバー

# 命令0007(pushtoarray)の実行直後
[1, 2, 3, 1]
self # レシーバー

concattoarrayするケース

splatについて理解ができたところでconcattoarrayが出てくるケースについてもみておきましょう。 例としてm(*a, *b)を考えます。 a[1, 2]b[3, 4]が返るとします。

# 0000 putself                                                          (  12)[Li]
# 0001 putself
# 0002 opt_send_without_block                 <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
# 0004 splatarray                             true
# 0006 putself
# 0007 opt_send_without_block                 <calldata!mid:b, argc:0, FCALL|VCALL|ARGS_SIMPLE>
# 0009 concattoarray
# 0010 opt_send_without_block                 <calldata!mid:m, argc:1, ARGS_SPLAT|ARGS_SPLAT_MUT|FCALL>
# 0012 leave
m(*a, *b)

先ほどとは異なり、今回はabをsplatした結果が欲しいので、bconcattoarrayでスタック上の配列に結合されます。

# 命令0004(splatarray)の実行直後
[1, 2]
self # レシーバー

# 命令0007(opt_send_without_block)の実行直後
[3, 4]
[1, 2]
self # レシーバー

# 命令0009(concattoarray)の実行直後
[1, 2, 3, 4]
self # レシーバー

splatより前に引数がある場合

m(1, 2, *a)という場合を考えてみましょう。

このとき[1, 2] + aという1つの配列を用意してargc = 1でメソッドを呼び出すこともできます。 しかしRubyはそうはしません。 a[3, 4]を返すとしましょう。

# 0000 putself                                                          (  10)[Li]
# 0001 putobject_INT2FIX_1_
# 0002 putobject                              2
# 0004 putself
# 0005 opt_send_without_block                 <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
# 0007 splatarray                             false
# 0009 opt_send_without_block                 <calldata!mid:m, argc:3, ARGS_SPLAT|FCALL>
# 0011 leave
m(1, 2, *a)

スタック上には3つの引数が積まれ、argc = 3でメソッドが呼び出されることになります。

# 命令0007(splatarray)の実行直後
[3, 4]
2
1
self # レシーバー

仮に[1, 2, 3, 4]argc = 1で呼び出したとしても、結局配列の内容がスタックに展開されます。

# メソッド呼び出しの直前
[1, 2, 3, 4]

# メソッド呼び出しのなかで引数のセットアップをした
4
3
2
1

スタック上に3つの引数を積んでargc = 3でメソッドを呼び出したときは以下のようになります。

# メソッド呼び出しの直前
[3, 4]
2
1

# メソッド呼び出しのなかで引数のセットアップをした
4
3
2 # ここはメソッド呼び出しの直前と変わらない
1 # ここはメソッド呼び出しの直前と変わらない

後者の方法であれば引数のセットアップ時に[1, 2]という配列を作らなくてすみますし、12についてはスタックの値を更新しなくてすみます。

というわけでRubyでは、「長さが実行時までわからない部分はまとめて1つの配列にいれて引数として渡すようにしている」ということができるのでした。

無駄なpushtoarrayを避ける

m(*a, 1, 2, 3, *b)というメソッド呼び出しを考えてみましょう。 ここまでの説明をもとにすると以下のような命令列になると考えられます。

  1. aに対してsplatarrayを実行して、arrayにする
  2. 1をarrayにpushする
  3. 2をarrayにpushする
  4. 3をarrayにpushする
  5. concattoarrayを実行して、bをarrayに結合する

実際に生成されるバイトコードをみてみると、pushtoarrayは1つしか存在しないことがわかります。 pushtoarrayは何個の要素をarrayにpushするかということをオペランドで指定できるので、1, 2, 3を都度arrayにpushするのではなく、まとめて3つpushするようなバイトコードが生成されます。

$ ruby --dump=i --parser=parse.y -e 'm(*a, 1, 2, 3, *b)'

0000 putself                                                          (   1)[Li]
0001 putself
0002 opt_send_without_block                 <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0004 splatarray                             true
0006 putobject_INT2FIX_1_
0007 putobject                              2
0009 putobject                              3
0011 pushtoarray                            3
0013 putself
0014 opt_send_without_block                 <calldata!mid:b, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0016 concattoarray
0017 opt_send_without_block                 <calldata!mid:m, argc:1, ARGS_SPLAT|ARGS_SPLAT_MUT|FCALL>
0019 leave

実装する

次の点に気をつけて実装をしていきます。

  • 最初のsplatにはsplatarray命令を、それ以降のsplatにはconcattoarray命令を生成する
  • splatとsplatの間の要素はまとめてpushtoarrayする
static int
compile_args(rb_iseq_t *iseq, LINK_ANCHOR *const ret, const rb_arguments_node_t *nd_args, unsigned int *flag_ptr, NODE **kwnode_ptr)
{
    int len = 0;
    int stack_len = 0;
    bool splatted = false;
    NODE *node;
    rb_node_list2_t *list = &nd_args->arguments;

    for (size_t i =0; i < RB_NODE_LIST_LEN(list); i++) {
        node = list->nodes[i];

        switch (nd_type(node)) {
          case RB_SPLAT_NODE:
            if (stack_len) {
                ADD_INSN1(ret, node, pushtoarray, INT2FIX(stack_len));
                stack_len = 0;
            }

            NO_CHECK(COMPILE(ret, "args (splat)", RB_NODE_SPLAT(node)->expression));

            if (!splatted) {
                ADD_INSN1(ret, node, splatarray, RBOOL(!splatted));
                len++;
            }
            else {
                ADD_INSN(ret, node, concattoarray);
            }
            splatted = true;
            *flag_ptr |= VM_CALL_ARGS_SPLAT;
            break;
          case RB_KEYWORD_HASH_NODE:
            // *kwnode_ptr = node;
            break;
          default:
            NO_CHECK(COMPILE_(ret, "array element", node, FALSE));

            if (!splatted) {
                len++;
            }
            else {
                stack_len++;
            }
            break;
        }
    }

    if (stack_len) {
        ADD_INSN1(ret, node, pushtoarray, INT2FIX(stack_len));
    }

    return len;
}

メソッド呼び出しをいくつか用意し、変更前後でコンパイル結果を比較してみます。

m1(nil)
m2(nil, false)
m3(*a)
m4(nil, *a)
m5(*a, nil)
m6(nil, *a, false)
m7(*a, *b)
m8(nil, *a, false, *b)
m9(nil, false, true, *a)

するといくつかのメソッド呼び出しでdiffがでます。 よくみるとdiffは2つのパターンにわけられます。

一つはsplatarrayオペランドが異なるというケース、もう一つはARGS_SPLAT_MUTの有無です。

splatarrayの最適化

一つ目のsplatarrayオペランドが異なるというケースについてみていきます。

 0012 putself                                                          (   3)[Li]
 0013 putself
 0014 opt_send_without_block                 <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
-0016 splatarray                             false
+0016 splatarray                             true
 0018 opt_send_without_block                 <calldata!mid:m3, argc:1, ARGS_SPLAT|FCALL>
 0020 pop
 0021 putself                                                          (   4)[Li]
 0022 putnil
 0023 putself
 0024 opt_send_without_block                 <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
-0026 splatarray                             false
+0026 splatarray                             true
 0028 opt_send_without_block                 <calldata!mid:m4, argc:2, ARGS_SPLAT|FCALL>

このような差異がm3, m4, m9に出ています。 それぞれどのような呼び出しになっているか再確認しましょう。

m3(*a)
m4(nil, *a)
m9(nil, false, true, *a)

おわかりいただけたでしょうか。

そうなんです。私も最初にこれをみたときはぞっとしました。

splatが1つ、かつ最後にくるケースではまだ最適化の余地があったのです。

何が起きているか理解するためにはsplatarrayオペランドについて知っておく必要があります。 先ほども書いたとおりsplatarrayという命令の実態はvm_splat_arrayという関数です。

/* call to_a on array ary to splat */
DEFINE_INSN
splatarray
(VALUE flag)
(VALUE ary)
(VALUE obj)
// attr bool leaf = false; /* has rb_check_array_type() */
{
    obj = vm_splat_array(flag, ary);
}

static VALUE
vm_splat_array(VALUE flag, VALUE ary)
{
    if (NIL_P(ary)) {
        return RTEST(flag) ? rb_ary_new() : rb_cArray_empty_frozen;
    }
    VALUE tmp = rb_check_to_array(ary);
    if (NIL_P(tmp)) {
        return rb_ary_new3(1, ary);
    }
    else if (RTEST(flag)) {
        return rb_ary_dup(tmp);
    }
    else {
        return tmp;
    }
}

vm_splat_arrayflagが真のときは必ずもとのarrayではない配列が作られることが保証されています6splatarray trueのときは常にdupすると理解すればいいでしょう。

さて、m4(nil, *a)のようにsplatが1つでそれが末尾にあるとき、この*aに破壊的な変更を加えるバイトコードは生成されるでしょうか。

そうですね。生成されませんね。

ということはdupする必要はないということになります。 いやぁここまで最適化するのかという気持ちになりますが、なんとかこれを実装しましょう。

VM_CALL_ARGS_SPLAT_MUT

もう一つの差分はopt_send_without_blockに渡すフラグの差分です。

 0035 splatarray                             true
 0037 putnil
 0038 pushtoarray                            1
-0040 opt_send_without_block                 <calldata!mid:m5, argc:1, ARGS_SPLAT|ARGS_SPLAT_MUT|FCALL>
+0040 opt_send_without_block                 <calldata!mid:m5, argc:1, ARGS_SPLAT|FCALL>
 0042 pop

VM_CALL_ARGS_SPLAT_MUT(dump optionでの表示はARGS_SPLAT_MUT)はsplat用に用意された配列に対して破壊的な操作をしてもよいということを表しています。 メソッドの呼び出し側ではいろいろと考えて、不必要なdupをさけたバイトコードを生成します。 しかし最終的にメソッドに渡された配列がdupされたものなのかどうかは、メソッド側からはわかりません。 メソッドは引数のセットアップ処理などでsplat用の配列に破壊的な操作をしたくなることがあります。 そのようなときにセットアップ処理のほうでdupする必要があるかどうかを判定するのにVM_CALL_ARGS_SPLAT_MUTを使います。

m5の呼び出しのときは0035 splatarray trueとなっているので、すでに呼び出し側でdupしています。 この場合はVM_CALL_ARGS_SPLAT_MUTを渡すことで、セットアップ処理のさいに自由に破壊していいということを伝えてあげる必要があります7

実装を変更する

既存の実装でこの辺りの処理を担っているのがsetup_args関数です。 この関数はいままでいじってきたcompile_argsの大元の呼び出し元です。 なんでcompile_argsをいじってsetup_argsを触ってこなかったかというと、まぁsetup_argsが大きいからです。 そうも言ってられなくなったので、ちゃんと読んでいきましょう。

#define SPLATARRAY_FALSE 0
#define SPLATARRAY_TRUE 1
#define DUP_SINGLE_KW_SPLAT 2

static VALUE
setup_args(rb_iseq_t *iseq, LINK_ANCHOR *const args, const rb_arguments_node_t *argn,
           unsigned int *flag, struct rb_callinfo_kwarg **keywords)
{
    VALUE ret;
    unsigned int dup_rest = SPLATARRAY_TRUE, initial_dup_rest;

    if (argn) {
        const NODE *check_arg = nd_type_p(argn, NODE_BLOCK_PASS) ?
            RNODE_BLOCK_PASS(argn)->nd_head : argn;

        if (check_arg) {
            switch(nd_type(check_arg)) {
              case(NODE_SPLAT):
                // avoid caller side array allocation for f(*arg)
                dup_rest = SPLATARRAY_FALSE;
                break;
              case(NODE_ARGSCAT):
                // avoid caller side array allocation for f(1, *arg)
                dup_rest = !nd_type_p(RNODE_ARGSCAT(check_arg)->nd_head, NODE_LIST);
                break;
              case(NODE_ARGSPUSH):
                // avoid caller side array allocation for f(*arg, **hash) and f(1, *arg, **hash)
                dup_rest = !((nd_type_p(RNODE_ARGSPUSH(check_arg)->nd_head, NODE_SPLAT) ||
                    (nd_type_p(RNODE_ARGSPUSH(check_arg)->nd_head, NODE_ARGSCAT) &&
                     nd_type_p(RNODE_ARGSCAT(RNODE_ARGSPUSH(check_arg)->nd_head)->nd_head, NODE_LIST))) &&
                    nd_type_p(RNODE_ARGSPUSH(check_arg)->nd_body, NODE_HASH) &&
                    !RNODE_HASH(RNODE_ARGSPUSH(check_arg)->nd_body)->nd_brace);

                if (dup_rest == SPLATARRAY_FALSE) {
                    // require allocation for keyword key/value/splat that may modify splatted argument
                    NODE *node = RNODE_HASH(RNODE_ARGSPUSH(check_arg)->nd_body)->nd_head;
                    while (node) {
                        NODE *key_node = RNODE_LIST(node)->nd_head;
                        if (key_node && setup_args_dup_rest_p(key_node)) {
                            dup_rest = SPLATARRAY_TRUE;
                            break;
                        }

                        node = RNODE_LIST(node)->nd_next;
                        NODE *value_node = RNODE_LIST(node)->nd_head;
                        if (setup_args_dup_rest_p(value_node)) {
                            dup_rest = SPLATARRAY_TRUE;
                            break;
                        }

                        node = RNODE_LIST(node)->nd_next;
                    }
                }
                break;
              default:
                break;
            }
        }

        if (check_arg != argn && setup_args_dup_rest_p(RNODE_BLOCK_PASS(argn)->nd_body)) {
            // for block pass that may modify splatted argument, dup rest and kwrest if given
            dup_rest = SPLATARRAY_TRUE | DUP_SINGLE_KW_SPLAT;
        }
    }
    initial_dup_rest = dup_rest;
...

ここまでが前半部分です。 前半ではSPLATARRAY_FALSE、つまりdupしなくていいケースに該当するかをチェックしています。 unsigned int dup_rest = SPLATARRAY_TRUEで初期化しているので基本戦略はdupをするです。

dup_restの取りうる値について説明しておくと、最下位1bitがSPLATARRAY true/falseを示していて、その1つ上のbitがDUP_SINGLE_KW_SPLAT、つまり**kwdupするかを示しています。

ここまでを踏まえて書き直します。 splatが1つかつ末尾にあるときにsplat falseにできること、また末尾がキーワード引数のときはそれをスキップして考えることに注意します。

static bool
setup_args_splat_last_p(rb_node_list2_t *list)
{
    size_t len = RB_NODE_LIST_LEN(list);

    switch (len) {
      case 0:
        return false;
      case 1:
        return nd_type_p(list->nodes[0], RB_SPLAT_NODE);
      default:
        return nd_type_p(list->nodes[len - 1], RB_SPLAT_NODE) || (nd_type_p(list->nodes[len - 1], RB_KEYWORD_HASH_NODE) && nd_type_p(list->nodes[len - 2], RB_SPLAT_NODE));
    }
}

static VALUE
setup_args(rb_iseq_t *iseq, LINK_ANCHOR *const args, const rb_arguments_node_t *argn,
           unsigned int *flag, struct rb_callinfo_kwarg **keywords)
{
    VALUE ret;
    unsigned int dup_rest = SPLATARRAY_TRUE, initial_dup_rest;
    rb_node_list2_t *list = &argn->arguments;

    if (argn) {
        size_t splatn = 0;

        // avoid caller side array allocation for f(*arg), f(1, *arg), f(*arg, **hash) and f(1, *arg, **hash)
        for (size_t i =0; i < RB_NODE_LIST_LEN(list); i++) {
            NODE *node = list->nodes[i];
            if (nd_type_p(node, RB_SPLAT_NODE)) splatn++;
        }

        if (splatn == 1 && setup_args_splat_last_p(list)) dup_rest = SPLATARRAY_FALSE;

        // TODO: Check kw
        // TODO: Check &blk

splatarray命令を生成する箇所ではsetup_argsで計算したdup_restをもとにオペランドを決定するようにします。

// Before
ADD_INSN1(ret, node, splatarray, RBOOL(!splatted));

// After
ADD_INSN1(ret, node, splatarray, RBOOL(*dup_rest & SPLATARRAY_TRUE));
if (*dup_rest & SPLATARRAY_TRUE) *dup_rest &= ~SPLATARRAY_TRUE;

VM_CALL_ARGS_SPLAT_MUTopt_send_without_blockに渡らないという問題は既存の仕組みにのることで解決できます。

setup_argsでは前半で計算したdup_restinitial_dup_restという変数に退避して、setup_args_coreの呼び出しによって値が変化したかをみます。 変化している場合にはsetup_args_splat_mutVM_CALL_ARGS_SPLAT_MUTをセットします。

static void
setup_args_splat_mut(unsigned int *flag, int dup_rest, int initial_dup_rest)
{
    if ((*flag & VM_CALL_ARGS_SPLAT) && dup_rest != initial_dup_rest) {
        *flag |= VM_CALL_ARGS_SPLAT_MUT;
    }
}

static VALUE
setup_args(rb_iseq_t *iseq, LINK_ANCHOR *const args, const rb_arguments_node_t *argn,
           unsigned int *flag, struct rb_callinfo_kwarg **keywords)
{
    initial_dup_rest = dup_rest;
    ret = INT2FIX(setup_args_core(iseq, args, argn, &dup_rest, flag, keywords));
    setup_args_splat_mut(flag, dup_rest, initial_dup_rest);
    return ret;
}

setup_args_coreおよびcompile_argsのなかでdup_restに変更を加えるのは、次のケースだけです。

            if (!splatted) {
                ADD_INSN1(ret, node, splatarray, RBOOL(*dup_rest & SPLATARRAY_TRUE));
                if (*dup_rest & SPLATARRAY_TRUE) *dup_rest &= ~SPLATARRAY_TRUE;
                len++;
            }
            else {
                ADD_INSN(ret, node, concattoarray);
            }

結局のところinitial_dup_restdup_restを比較するというのはsplatarray trueという命令を生成したかどうかを判定することと同値になっています。 splatarray trueなのであれば、それは以降の処理でsplat用の配列に破壊的な変更を加えても大丈夫なので、VM_CALL_ARGS_SPLAT_MUTのフラグを立てて問題ありません。

修正の結果を確認します。 m3(*a)のようにdupを必要としないケースではsplatarray falseになっています。

// 修正前
0013 putself
0014 opt_send_without_block                 <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0016 splatarray                             true
0018 opt_send_without_block                 <calldata!mid:m3, argc:1, ARGS_SPLAT|FCALL>
0020 pop

// 修正後
0013 putself
0014 opt_send_without_block                 <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0016 splatarray                             false
0018 opt_send_without_block                 <calldata!mid:m3, argc:1, ARGS_SPLAT|FCALL>
0020 pop

またsplatarray trueのときはARGS_SPLAT_MUTのフラグが渡るようにもなっています。

// 修正前
0035 splatarray                             true
0037 putnil
0038 pushtoarray                            1
0040 opt_send_without_block                 <calldata!mid:m5, argc:1, ARGS_SPLAT|FCALL>
0042 pop

// 修正後
0035 splatarray                             true
0037 putnil
0038 pushtoarray                            1
0040 opt_send_without_block                 <calldata!mid:m5, argc:1, ARGS_SPLAT|ARGS_SPLAT_MUT|FCALL>
0042 pop

まとめ

今日の成果です。

  • splatのある引数を扱えるようになった

長かった...

次回はキーワード引数とブロックをコンパイルしていきます。


  1. 最適化の一環などで命令のオペランドに直接値を埋め込むこともあります。
  2. より正確には引数の調整を行うレイヤーがあり、そこが渡された引数に関する情報を必要としています。
  3. 引数の数以外にも、splatの有無などいくつかの情報が必要です。
  4. 数値リテラルが未対応なのでtruefalseなどを使っています。
  5. さすがに文字列(シンボルであって文字列ではないが)が扱えないとつまらないので、symbolリテラルはしれっとサポートしました。特に難しいことはないので実装の詳細は割愛します。
  6. splatarrayflagが真のときにdupするのか偽のときにdupするのか調べるためにvm_splat_arrayの実装を読むの、今年だけで5回くらいやっている気がします...
  7. 伝え忘れるとセットアップ処理側でもdupをすることになってしまい非効率だからです。



以上の内容はhttps://yui-knk.hatenablog.com/entry/2025/12/23/115553より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14