FireDucks に隠し機能を作ろうと思ったけどボツになった話

LICENSE: CC BY 4.0[^ccby], 0BSD[^zerobsd]

こちらは [NEC デジタルテクノロジー開発研究所 Advent Calendar 2023](https://qiita.com/advent-calendar/2023/nec-dt-labs) 20日目の記事です。[FireDucks](https://fireducks-dev.github.io/ja/) に実装しようと思っていた隠し機能のボツ案についてご紹介したいと思います。

# いきなり結論

メソッド毎に実行時間を計測するようなマイクロベンチマークをしたいときには、FireDucks 0.9.1 で追加された「ベンチマークモード」を使いましょう。

```shell
FIREDUCKS_FLAGS="--benchmark-mode" python3 -m fireducks.imhook your_program.py
```

このベンチマークモードが新たに追加されたことによって、私が提案していた隠し機能はほぼ不要になりました。

それはそれとして、この記事ではどういう隠し機能を作ろうとしていたか、作ろうと思ったきっかけから順を追って説明していきたいと思います。

# FireDucks の実行時間計測におけるよくある間違い

## 時間計測が間違っているケース

以下のようなコードで FireDucks の実行時間を計測したいと思ったとします。

```python:corr.py
import fireducks.pandas as pd
from time import perf_counter as pc

t0 = pc()
df = pd.read_csv("input.csv")
t1 = pc()
print(f"'read_csv()' を実行した？: {t1 - t0:.9f} sec")

t0 = pc()
result = df.corr()
t1 = pc()
print(f"'corr()' を実行した？:     {t1 - t0:.9f} sec")

t0 = pc()
print(result)
t1 = pc()
print(f"'print()' を実行した？:    {t1 - t0:.9f} sec")
```

10GB 弱の csv ファイルを用意して実行してみました。

```shell
$ python3 corr.py
'read_csv()' を実行した？: 0.000420314 sec
'corr()' を実行した？:     0.000087551 sec
          alice     brian     carol     david     ellen     frank     grace     harry
alice  1.000000  0.000165  0.000183  0.000176  0.000181  0.980528  0.000227  0.000221
brian  0.000165  1.000000  0.000022  0.997188  0.000018  0.000154  0.000038  0.000035
carol  0.000183  0.000022  1.000000  0.000028  0.999896  0.000179 -0.000059 -0.000052
david  0.000176  0.997188  0.000028  1.000000  0.000024  0.000164  0.000026  0.000024
ellen  0.000181  0.000018  0.999896  0.000024  1.000000  0.000177 -0.000056 -0.000049
frank  0.980528  0.000154  0.000179  0.000164  0.000177  1.000000  0.000242  0.000239
grace  0.000227  0.000038 -0.000059  0.000026 -0.000056  0.000242  1.000000  0.997448
harry  0.000221  0.000035 -0.000052  0.000024 -0.000049  0.000239  0.997448  1.000000
'print()' を実行した？:    4.181785367 sec
```

`read_csv()` で csv ファイルを読み込むのに約400マイクロ秒、`corr()` で相関係数を計算するのに約80マイクロ秒、`print()` で結果を画面に表示するのに約4秒かかっているように見えます。このような結果を見たら**おかしいと思わなければいけません**。いくらなんでも 10GB のデータを1ミリ秒未満の時間で読み込むことができるはずがありません。そしてたった8行8列のデータフレームを標準出力に書き出すのに4秒もかかっているのも奇妙です。

[FireDucks 公式ウェブサイト](https://fireducks-dev.github.io/ja/)でも解説している通り、FireDucks は遅延実行モデルを採用しています。

https://fireducks-dev.github.io/ja/docs/user-guide/02-exec-model/

つまり先ほどの例で言うと、`read_csv()` を実行しろと言われたときも `corr()` を実行しろと言われたときも、実際にはファイルを読み込んだり相関係数を計算したりして**いません**。それらの処理に対応する中間言語を作るだけしかやっていないのです。その後 `print()` しろと言われて結果を出力する必要に迫られて、そこで初めて `read_csv()` と `corr()` の中間言語をまとめて実行して結果を出力します。

## 正しく時間計測できるように修正したコード

作成された中間言語を実行することを「評価する」と言います。Python プログラムの中で「今ここで評価して！」と明示的に指示するには、FireDucks の独自メソッドである `_evaluate()` を呼びます。

```python:corr.py
import fireducks.pandas as pd
from time import perf_counter as pc

t0 = pc()
df = pd.read_csv("input.csv")
df._evaluate()  # ← 今ここで評価
t1 = pc()
print(f"'read_csv()' を実行した！: {t1 - t0:.9f} sec")

t0 = pc()
result = df.corr()
result._evaluate()  # ← 今ここで評価
t1 = pc()
print(f"'corr()' を実行した！:     {t1 - t0:.9f} sec")

t0 = pc()
print(result)
t1 = pc()
print(f"'print()' を実行した！:    {t1 - t0:.9f} sec")
```

```shell
$ python3 corr.py
'read_csv()' を実行した！: 2.559414894 sec
'corr()' を実行した！:     1.572788588 sec
          alice     brian     carol     david     ellen     frank     grace     harry
alice  1.000000  0.000165  0.000183  0.000176  0.000181  0.980528  0.000227  0.000221
brian  0.000165  1.000000  0.000022  0.997188  0.000018  0.000154  0.000038  0.000035
carol  0.000183  0.000022  1.000000  0.000028  0.999896  0.000179 -0.000059 -0.000052
david  0.000176  0.997188  0.000028  1.000000  0.000024  0.000164  0.000026  0.000024
ellen  0.000181  0.000018  0.999896  0.000024  1.000000  0.000177 -0.000056 -0.000049
frank  0.980528  0.000154  0.000179  0.000164  0.000177  1.000000  0.000242  0.000239
grace  0.000227  0.000038 -0.000059  0.000026 -0.000056  0.000242  1.000000  0.997448
harry  0.000221  0.000035 -0.000052  0.000024 -0.000049  0.000239  0.997448  1.000000
'print()' を実行した！:    0.012410008 sec
```

正しく時間計測ができました。

# データフレームの評価におけるうっかりミス

`_evaluate()` メソッドは FireDucks の独自 API であるため、これを使ってしまうと pandas では動かなくなってしまいます。FireDucks には[インポートフック機能](https://fireducks-dev.github.io/ja/docs/get-started/get-started/#import-hook)もあるため、pandas でも FireDucks でもどっちでも動くコードを作りたくなってしまいますね。ということで、以下のようなコードを書いてしまいがちです。

```python
if hasattr(df, "_evaluate"):
    df._evaluate()
```

不格好ですが、別にこれ自体は悪くはないのです。

## pandas でも FireDucks でも動くようにしたくなって間違ってしまったケース

ところが以下のコードでは実行時間の計測が間違った結果になります。なぜだかわかるでしょうか。

```python:corr_with_typo.py
import pandas as pd
from time import perf_counter as pc

def my_evaluate(df):
    # ヒント：ここが間違っています
    if hasattr(df, "_evalaute"):
        df._evaluate()

t0 = pc()
df = pd.read_csv("input.csv")
my_evaluate(df)
t1 = pc()
print(f"'read_csv()' を実行した？: {t1 - t0:.9f} sec")

t0 = pc()
result = df.corr()
my_evaluate(result)
t1 = pc()
print(f"'corr()' を実行した？:     {t1 - t0:.9f} sec")

t0 = pc()
print(result)
t1 = pc()
print(f"'print()' を実行した？:    {t1 - t0:.9f} sec")
```

あっ！ `hasattr` の部分に typo があります！ _イヴァリュエート_ と書きたかったのに _イヴァラウテ_ になってしまっていますね。pandas にも FireDucks にもイヴァラウテなどというメソッドは存在しないため、これでは正しく評価が行われません。しかし `hasattr` は単に `False` を返すだけであり、間違いに気づきにくいのです。

:::note warn
上のコードはこの typo のせいで余計に時間がかかるようになってしまっています。なぜならば `hasattr` でイヴァラウテという属性が見つからないときに[フォールバック機能](https://fireducks-dev.github.io/ja/docs/user-guide/tips/#%E3%83%95%E3%82%A9%E3%83%BC%E3%83%AB%E3%83%90%E3%83%83%E3%82%AF%E3%82%92%E9%81%BF%E3%81%91%E3%82%8B)が発動するためです。

フォールバックとは、FireDucks で未実装のメソッドの処理をオリジナルの pandas のメソッドへ丸投げする機能のことですが、そのためにいったん FireDucks 形式のデータを pandas 形式のデータに変換してオリジナルの pandas の DataFrame オブジェクトを作成してしまいます。そして改めて `hasattr` でイヴァラウテなんて属性はオリジナルの pandas にもないという結論に至ります。わざわざ無用なデータ変換をやってしまっているんですね…。
:::

# 安全にデータフレームを評価するための隠し機能案

## 安全な評価の要件

さて、typo 問題を回避できる機能として、以下のような条件を満たすものを作りたいと思いました。

- pandas では何も起こらない（エラーにならない）
- FireDucks では明示的な評価が行われる
- FireDucks から pandas へのフォールバックは発生しないようにしたい
- typo したら（pandas でも FireDucks でも）実行時エラーになって絶対に気づくようにしたい
    - `hasattr` でチェックしたり `AttributeError` をキャッチして捨てたりする方法だと気づかない可能性が高い
- できるだけ簡潔に書ける
    - pandas だけで動かす際に FireDucks をインポートする必要がないようにしたい

要するに pandas の標準 API の中から実質 NOP (no operation) になるようなものを探して、FireDucks の場合はそこでデータフレームの評価を行うようにすればいいわけです。

## 実装の方針

で、見つけたのが `pd.eval()` 関数です。この eval はデータフレームの評価とは別の意味の eval で、これは文字列オブジェクトを式として評価して実行してもらうための関数です。[pandas 公式 API ドキュメント](https://pandas.pydata.org/docs/reference/api/pandas.eval.html)から例を引用しましょう。

> **Examples**
>
> ```python
> >>> df = pd.DataFrame({"animal": ["dog", "pig"], "age": [10, 20]})
> >>> df
>   animal  age
> 0    dog   10
> 1    pig   20
> ```
>
> We can add a new column using `pd.eval`:
>
> ```python
> >>> pd.eval("double_age = df.age * 2", target=df)
>   animal  age  double_age
> 0    dog   10          20
> 1    pig   20          40
> ```
> -- <em>https://pandas.pydata.org/docs/reference/api/pandas.eval.html</em>

`"double_age = df.age * 2"` という文字列を式として実行することで、`target` 引数に指定された元の `df` をコピーして `double_age` という列を新しく追加したデータフレームが返ってきています。実行したい内容を文字列オブジェクトで動的に指定することができる関数ですね。

この `pd.eval()` 関数に数値リテラルや真偽値リテラルを渡すと、データフレーム的な処理を何もせずに単に渡した値が返ってくるだけであることを発見しました。

```python
>>> pd.eval(42, target=df)
42
>>> pd.eval(True, target=df)
True
```

:::note warn
この記事の執筆時点の動作です。第一引数に文字列でないものを渡した場合の処理は [pandas 公式 API ドキュメント](https://pandas.pydata.org/docs/reference/api/pandas.eval.html)に記載されていない内容であるため、将来のアップデートにより異なる動作になるかもしれません。もしかしたら現在でも環境によって異なる動作になるかもしれません。
:::

ということで、`pd.eval()` 関数の第一引数に文字列ではなく `True` が与えられた場合に限り、FireDucks では `target` 引数に与えられたデータフレームの評価（中間言語の実行）を行えばよいのではないか？ という結論に至りました。

## そしてボツへ

先月末ぐらいだったと思いますが、FireDucks 開発チームでこの新機能案について議論したところ、以下のような意見が得られました。

- 気持ち悪い
- 気持ち悪いよ
- これはストレンジですね
- データフレーム評価の `_evaluate` と式の評価の `pd.eval` で紛らわしい
- この隠し機能を使う人が増えてしまった後に `pd.eval()` 関数の仕様が変更になったらどーすんの？ :wink:
- いやかなり気持ち悪いよ

わかる～！ 私も気持ち悪いと思ってたところだったんですよ！

# まとめにかえて

冒頭でも述べましたが、先週 FireDucks 0.9.1 で「ベンチマークモード」という機能が追加されたため、この隠し機能案を復活させる予定はありません。ベンチマークモードはデータフレームを評価し忘れる問題に対する別の切り口からの解決策と言えます。

> ベンチマークモードが有効な場合，FireDucks はメソッドが呼び出された直後にそのメソッドを実行します．これは FireDucks のいくつかの最適化を無効にするため，個々のメソッドを測定したい場合にのみ使用してください．
>
> ベンチマークモードを有効にするには，以下のように環境変数を指定してください．
>
> ```shell
> FIREDUCKS_FLAGS="--benchmark-mode"
> ```
>
> -- <em>https://fireducks-dev.github.io/ja/docs/user-guide/02-exec-model/#about-time-measurement</em>

ベンチマークモードを有効にすると、中間言語における実行順序の入れ替えや途中の計算結果の使い回しなどの最適化が無効になってしまいますが、メソッド毎に実行時間を計測するようなマイクロベンチマークがやりやすくなります。

ちなみに中間言語における最適化については FireDucks ユーザーガイドや Advent Calendar の記事で紹介していますので、是非こちらも読んでみてください。

https://fireducks-dev.github.io/ja/docs/user-guide/03-acceleration/

https://qiita.com/takuya-araki/items/38c3554ba72c9eb274ba

FireDucks 開発チームではさらなる高速化や使い勝手の改善のために日々様々な課題に取り組んでいます。今後のアップデートにも是非ご期待ください！

[^ccby]: 本記事のライセンスは [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/deed.ja) とします。

[^zerobsd]: 前記1に関わらず、引用部分を除き、本記事内のコードスニペットのライセンスは [0BSD](https://licenses.opensource.jp/0BSD/0BSD.html) とします。