概要
CPythonコア開発者の@methane さんが以下のようなツイートをされていて、@methane さん曰く「簡単そう」ということなのでGWの空き時間で実装してみました。そのメモと結果をまとめます。
Pythonのメモリ消費をケチるアイデア、docstringをコンパイル時にdedentするって案があって割とコスパ良いと予想してるんだけど、だれかやってみたい人いない?
— Inada Naoki (@methane) 2019年4月17日
で、これのほぼ答え的なヒントがScrapbox.ioに残されているので、それを頼りに実装してみます。
docstringのインデントを削る - CPython Development
結論だけ先に書くとまだ検証(コード修正)が必要な部分があります。一旦ここまでのメモを書き出しています(2019/05/04)
前提
OSはLinux(dockerのubuntu:latest)
CPythonのターゲットは https://github.com/python/cpython からcloneしてきた4/30現在の最新リビジョンだった 06d04e77ca36133e82bf6c363c09ba82e07a9c75 を使っています。
(もう少し付け加えるとforkしたリポジトリを使っています。)
macOSXでmake testが失敗するので、 98a1e06c47f655c7601b130cf8d549de9f08369e を使っています。
Python3.8.0a3ベースです。
$ git clone git@github.com:hhatto/cpython.git
$ cd cpython
$ git branch 98a1e06c47f655c7601b130cf8d549de9f08369e
CPythonのビルド
$ ./configure --prefix=$HOME/.mydevpython --enable-optimizations
$ make
$ make install
リリースビルド用に--enable-optimizations
は付けておいた方が良さそう。
--prefix
は環境に応じて変えます。
ビルドに関するメモ
- CPythonのコンパイラのコードを触った際は
profile-run-stamp
ファイルは削除する
実装
インデントを削る処理を探す
まずは下記のインデントを削る処理を探します。
help(func) とかをしたときにどうやって docstring からインデントを削っているかは、
_sitebuiltins.py
の Helper クラスから追っかけていけばわかるはず。
と言うことなので、愚直に辿ってみます。
Lib/_sitebuiltins.py:103 pydoc.help
Lib/pydoc.py:1938 Helper.helpのdoc()
Lib/pydoc.py:1676 render_doc()
Lib/pydoc.py:1669 renderer.document()
このrendererはTextDoc()
Lib/pydoc.py:1063
ざっと見ていってgetdoc()というのがいかにもそれっぽい...
Lib/pydoc.py:92 getdoc()
inspect.getdoc()が本丸か...
Lib/inspect.py:605 cleandoc()
Lib/inspect.py:624
で、このLib/inspect.py
のcleandoc()
は以下のようなコードになっています。
def cleandoc(doc):
"""Clean up indentation from docstrings.
Any whitespace that can be uniformly removed from the second line
onwards is removed."""
try:
lines = doc.expandtabs().split('\n')
except UnicodeError:
return None
else:
# Find minimum indentation of any non-blank lines after first line.
margin = sys.maxsize
for line in lines[1:]:
content = len(line.lstrip())
if content:
indent = len(line) - content
margin = min(margin, indent)
# Remove indentation.
if lines:
lines[0] = lines[0].lstrip()
if margin < sys.maxsize:
for i in range(1, len(lines)): lines[i] = lines[i][margin:]
# Remove any trailing or leading blank lines.
while lines and not lines[-1]:
lines.pop()
while lines and not lines[0]:
lines.pop(0)
return '\n'.join(lines)
これをCで実装すれば良いということになります。
愚直に実装したコード
inspect.py
のcleandoc()
をほぼCPython APIで実装した感じ。
PyObject_CallMethod()
を多用しているので効率はあまり良くないと思う。
本当はPyObjectから生の文字列を取り出して、C側で文字列処理を頑張った方が良さそう。
Lib/inspect.py
のcleandoc()
の# Remove any trailing or leading blank lines.
のコメントが付いている箇所はdoctestの機能に影響があるようで、1行目を除いた先頭と末尾の空行を削除する処理は含めない形に変更しました。(2019/05/05追記)
static PyObject *
cleanup_docstring(PyObject *docstring)
{
PyObject *expanded = PyObject_CallMethod(docstring, "expandtabs", NULL);
PyObject *lines = PyObject_CallMethod(expanded, "split", "s", "\n");
Py_DECREF(expanded);
Py_ssize_t lines_num = PySequence_Length(lines);
long margin = PY_SSIZE_T_MAX;
// Find minimum indentation of any non-blank lines after first line.
if (lines_num > 1) {
for (Py_ssize_t i=1; i<lines_num; i++) {
PyObject *line = PySequence_GetItem(lines, i);
PyObject *lstrip_line = PyObject_CallMethod(line, "lstrip", NULL);
Py_ssize_t content_len = PyUnicode_GET_LENGTH(lstrip_line);
if (content_len) {
Py_ssize_t line_len = PyUnicode_GET_LENGTH(line);
Py_ssize_t indent_len = line_len - content_len;
if (margin > indent_len) {
margin = indent_len;
}
}
Py_DECREF(line);
Py_DECREF(lstrip_line);
}
}
// Remove indentation.
if (lines_num >= 1) {
PyObject *first_line = PySequence_GetItem(lines, 0);
PyObject *first_lstrip_line = PyObject_CallMethod(first_line, "lstrip", NULL);
PySequence_SetItem(lines, 0, first_lstrip_line);
Py_DECREF(first_line);
}
if (margin < PY_SSIZE_T_MAX) {
for (Py_ssize_t i=1; i<lines_num; i++) {
PyObject *line = PySequence_GetItem(lines, i);
Py_ssize_t line_len = PyUnicode_GET_LENGTH(line);
PyObject *strip_margin_line = PyUnicode_Substring(line, margin, line_len);
PySequence_SetItem(lines, i, strip_margin_line);
Py_DECREF(line);
}
}
PyObject *rt = PyUnicode_FromFormat("\n");
PyObject *ret = PyObject_CallMethod(rt, "join", "O", lines);
Py_DECREF(lines);
Py_DECREF(rt);
return ret;
}
.pycコンパイル処理にdocstringを設定している処理を辿る
Cでdocstringのインデントを削る処理は実装できたので、あとはどこに処理を差し込むかを調べます。
コンパイラのどの部分で実装すればいいかは、
-OO
オプションがあったときに docstring を捨てている部分を探せば簡単にたどり着ける。
1つは既に答えを書いてくださっていて、 https://github.com/python/cpython/blob/06d04e77ca36133e82bf6c363c09ba82e07a9c75/Python/compile.c#L1700 にあります。
もう1つ該当箇所があって、 https://github.com/python/cpython/blob/06d04e77ca36133e82bf6c363c09ba82e07a9c75/Python/compile.c#L2121 がそれ。
if (c->c_optimize < 2) {
docstring = _PyAST_GetDocString(stmts);
if (docstring) {
i = 1;
st = (stmt_ty)asdl_seq_GET(stmts, 0);
assert(st->kind == Expr_kind);
VISIT(c, expr, st->v.Expr.value);
if (!compiler_nameop(c, __doc__, Store))
return 0;
}
}
if (c->c_optimize < 2) {
docstring = _PyAST_GetDocString(body);
}
if (compiler_add_const(c, docstring ? docstring : Py_None) < 0) {
compiler_exit_scope(c);
return 0;
}
この2箇所のはず...
どちらもdocstring
にインデントを含んだdocstringが入っているので、ここでインデントを削ります。
if (c->c_optimize < 2) {
docstring = _PyAST_GetDocString(stmts);
if (docstring) {
i = 1;
st = (stmt_ty)asdl_seq_GET(stmts, 0);
assert(st->kind == Expr_kind);
+ st->v.Expr.value->v.Constant.value = cleanup_docstring(st->v.Expr.value->v.Constant.value);
VISIT(c, expr, st->v.Expr.value);
if (!compiler_nameop(c, __doc__, Store))
return 0;
}
}
if (c->c_optimize < 2) {
docstring = _PyAST_GetDocString(body);
+ if (docstring) {
+ docstring = cleanup_docstring(docstring);
+ }
}
これで実装は終わりです。
ビルドして検証してみます。
検証結果
メモリ使用量と.pycのサイズをチェックします。
.pycの作成処理込みで調べたいので、lib/python3.8/__pycache__
をディレクトリごと削除してから検証します。
実装前の結果
$ ~/.mypython/bin/python3
Python 3.8.0a3+ (heads/master:06d04e77ca, May 4 2019, 14:10:48)
[GCC 7.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>>
初回起動
# CPython起動直後
$ ps aux | grep python | grep -v grep
root 42938 0.2 0.4 21856 9188 pts/0 S+ 14:18 0:00 /root/.mypython/bin/python3
# zipfileとftplibをimport後
$ ps aux | grep python | grep -v grep
root 42938 0.3 0.6 43764 12588 pts/0 S+ 14:18 0:00 /root/.mypython/bin/python3
.pycのサイズ
$ ls -ltr lib/python3.8/__pycache__/zipfile.cpython-38.pyc
-rw-r--r-- 1 root root 49927 May 4 14:19 lib/python3.8/__pycache__/zipfile.cpython-38.pyc
$ ls -ltr lib/python3.8/__pycache__/ftplib.cpython-38.pyc
-rw-r--r-- 1 root root 27472 May 4 14:19 lib/python3.8/__pycache__/ftplib.cpython-38.pyc
2回目の起動
# CPython起動直後
$ ps aux | grep python | grep -v grep
root 42947 0.2 0.3 20496 7500 pts/0 S+ 14:22 0:00 /root/.mypython/bin/python3
# zipfileとftplibをimport後
$ ps aux | grep python | grep -v grep
root 42947 0.1 0.4 41676 10224 pts/0 S+ 14:22 0:00 /root/.mypython/bin/python3
2回目以降の起動時のメモリ使用量をvalgrindでプロファイルした結果
MB
1.190^ #
| @ ::#::::
| @ :::@:::#::::
| @:::::@:::#:::::
| @:::::@:::#:::::
| @:::::@:::#:::::
| @:@@:@:::::@:::#::::@
| :@:::@@:@@:@:::::@:::#::::@
| @:::@:::@@:@:: @@:@@:@:::::@:::#::::@:
| : @@@:::@:::@@:@:: @@:@@:@:::::@:::#::::@:
| ::::@@@:::@:::@@:@:: @@:@@:@:::::@:::#::::@:
| @@:::::::::::::@@@:::@:::@@:@:: @@:@@:@:::::@:::#::::@:
| ::::@@: :::::: ::::@@@:::@:::@@:@:: @@:@@:@:::::@:::#::::@:
| :::::@@: :::::: ::::@@@:::@:::@@:@:: @@:@@:@:::::@:::#::::@:
| @@:::::@@: :::::: ::::@@@:::@:::@@:@:: @@:@@:@:::::@:::#::::@:
| ::@ :::::@@: :::::: ::::@@@:::@:::@@:@:: @@:@@:@:::::@:::#::::@:
| ::::@ :::::@@: :::::: ::::@@@:::@:::@@:@:: @@:@@:@:::::@:::#::::@:
| ::::@ :::::@@: :::::: ::::@@@:::@:::@@:@:: @@:@@:@:::::@:::#::::@:
| @:@::::@ :::::@@: :::::: ::::@@@:::@:::@@:@:: @@:@@:@:::::@:::#::::@:
| @:@:@::::@ :::::@@: :::::: ::::@@@:::@:::@@:@:: @@:@@:@:::::@:::#::::@:
0 +----------------------------------------------------------------------->MB
0 242.4
docstringを削った後の結果
$ ~/.mydevpython/bin/python3
Python 3.8.0a3+ (heads/cleanup-docstring-in-pyc:c89c0ee355, May 4 2019, 14:59:24)
[GCC 7.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>>
初回起動
# CPython起動直後
$ ps aux | grep python | grep -v grep
root 63788 1.0 0.4 21644 9008 pts/0 S+ 15:01 0:00 /root/.mydevpython/bin/python3
# zipfileとftplibをimport後
$ ps aux | grep python | grep -v grep
root 63788 1.0 0.6 44028 12788 pts/0 S+ 15:01 0:00 /root/.mydevpython/bin/python3
.pycのサイズ
$ cd $HOME/.mydevpython/lib/python3.8/
$ ls -ltr __pycache__/zipfile.cpython-38.pyc
-rw-r--r-- 1 root root 49349 May 4 15:01 __pycache__/zipfile.cpython-38.pyc
$ ls -ltr __pycache__/ftplib.cpython-38.pyc
-rw-r--r-- 1 root root 26478 May 4 15:01 __pycache__/ftplib.cpython-38.pyc
2回目の起動
# CPython起動直後
$ # ps aux | grep python | grep -v grep
root 63798 0.1 0.3 20504 7596 pts/0 S+ 15:04 0:00 /root/.mydevpython/bin/python3
# zipfileとftplibをimport後
$ ps aux | grep python | grep -v grep
root 63798 0.1 0.4 41388 10156 pts/0 S+ 15:04 0:00 /root/.mydevpython/bin/python3
2回目以降の起動時のメモリ使用量をvalgrindでプロファイルした結果
MB
1.166^ #
| # ::::::@
| # :::@:::::::@
| #:::::@:::::::@
| #:::::@:::::::@
| @@#:::::@:::::::@
| @ :@ #:::::@:::::::@:
| @ :::::@::@ #:::::@:::::::@::
| @::@:::::@::::: @::@ #:::::@:::::::@::
| @ :@@@::@: :::@::::: @::@ #:::::@:::::::@::
| @ @@:::@@@::@: :::@::::: @::@ #:::::@:::::::@::
| @@:::@::::@@: :@@@::@: :::@::::: @::@ #:::::@:::::::@::
| @:::@@:: @::::@@: :@@@::@: :::@::::: @::@ #:::::@:::::::@::
| :@:::@@:: @::::@@: :@@@::@: :::@::::: @::@ #:::::@:::::::@::
| :::@:::@@:: @::::@@: :@@@::@: :::@::::: @::@ #:::::@:::::::@::
| ::: :@:::@@:: @::::@@: :@@@::@: :::@::::: @::@ #:::::@:::::::@::
| :::::: :@:::@@:: @::::@@: :@@@::@: :::@::::: @::@ #:::::@:::::::@::
| : :::: :@:::@@:: @::::@@: :@@@::@: :::@::::: @::@ #:::::@:::::::@::
| ::@: :::: :@:::@@:: @::::@@: :@@@::@: :::@::::: @::@ #:::::@:::::::@::
| :: @: :::: :@:::@@:: @::::@@: :@@@::@: :::@::::: @::@ #:::::@:::::::@::
0 +----------------------------------------------------------------------->MB
0 243.9
.pycは小さくなっていますが、メモリ使用量は有意な差と言えるかどうか...(ピーク使用量は減っていてプログラム全体のライフサイクルの中でのメモリ使用量は増えています。)
一旦は.pycのファイルサイズが小さくなったので良しとします。
追加検証
methaneさんからコメントをもらってSQLAlchemyで検証してみると良いかも、ということでSQLAlchemyのリポジトリにあるexampleなコードで検証してみます。
examples/association/basic_association.pyを検証コードに使用します。
$ valgrind --tool=massif --time-unit=B --stacks=yes --max-stackframe=2000028 python examples/association/basic_association.py
$ ms_print massif.out.XXXX
インデント削る前
MB
4.671^ #
| @@@:#:
| @@:@:@@@:#:
| @:::@@@:@:@@@:#:
| @:@:@:: @@@:@:@@@:#:
| :::::@:@:@:: @@@:@:@@@:#:
| : :::@:@:@:: @@@:@:@@@:#::
| @:: :::@:@:@:: @@@:@:@@@:#::
| @:::::@:@:: :::@:@:@:: @@@:@:@@@:#::
| :::@: :: @:@:: :::@:@:@:: @@@:@:@@@:#::
| @@::::@:: @: :: @:@:: :::@:@:@:: @@@:@:@@@:#::
| :@ :: :@:: @: :: @:@:: :::@:@:@:: @@@:@:@@@:#::
| :::::::@ :: :@:: @: :: @:@:: :::@:@:@:: @@@:@:@@@:#::
| @::: ::: :@ :: :@:: @: :: @:@:: :::@:@:@:: @@@:@:@@@:#::
| ::@::: ::: :@ :: :@:: @: :: @:@:: :::@:@:@:: @@@:@:@@@:#::
| @:::@::: ::: :@ :: :@:: @: :: @:@:: :::@:@:@:: @@@:@:@@@:#::
| :::@:::@::: ::: :@ :: :@:: @: :: @:@:: :::@:@:@:: @@@:@:@@@:#::
| :@: :@:::@::: ::: :@ :: :@:: @: :: @:@:: :::@:@:@:: @@@:@:@@@:#::
| :::::@: :@:::@::: ::: :@ :: :@:: @: :: @:@:: :::@:@:@:: @@@:@:@@@:#::
| @@: :::@: :@:::@::: ::: :@ :: :@:: @: :: @:@:: :::@:@:@:: @@@:@:@@@:#::
0 +----------------------------------------------------------------------->GB
0 1.435
インデント削った後
MB
4.471^ #
| @:@:#:
| @@:::@:@:#:
| @::@@@: :@:@:#:
| ::@@@:@: @@@: :@:@:#:
| ::::: @@ :@: @@@: :@:@:#:
| : ::: @@ :@: @@@: :@:@:#:
| :: ::: @@ :@: @@@: :@:@:#:
| :::::::::: ::: @@ :@: @@@: :@:@:#::
| @::::: ::: :: ::: @@ :@: @@@: :@:@:#::
| @@::::@: ::: ::: :: ::: @@ :@: @@@: :@:@:#::
| @::@@::: @: ::: ::: :: ::: @@ :@: @@@: :@:@:#::
| :::::@: @@::: @: ::: ::: :: ::: @@ :@: @@@: :@:@:#::
| :@@:: : @: @@::: @: ::: ::: :: ::: @@ :@: @@@: :@:@:#::
| ::@:@ :: : @: @@::: @: ::: ::: :: ::: @@ :@: @@@: :@:@:#::
| @::: @:@ :: : @: @@::: @: ::: ::: :: ::: @@ :@: @@@: :@:@:#::
| ::@::: @:@ :: : @: @@::: @: ::: ::: :: ::: @@ :@: @@@: :@:@:#::
| ::@: @::: @:@ :: : @: @@::: @: ::: ::: :: ::: @@ :@: @@@: :@:@:#::
| :::: @: @::: @:@ :: : @: @@::: @: ::: ::: :: ::: @@ :@: @@@: :@:@:#::
| :::::: @: @::: @:@ :: : @: @@::: @: ::: ::: :: ::: @@ :@: @@@: :@:@:#::
0 +----------------------------------------------------------------------->GB
0 1.400
ピークで約200KB、トータルで約35MBの削減という感じでしょうか。
.pycのサイズも念の為共有。
# 改修前
$ ls -ltr ~/.virtualenvs/mypython38nottrim/lib/python3.8/site-packages/sqlalchemy/__pycache__/
total 128
-rw-r--r-- 1 root root 12456 May 5 14:47 interfaces.cpython-38.pyc
-rw-r--r-- 1 root root 17378 May 5 14:47 exc.cpython-38.pyc
-rw-r--r-- 1 root root 4257 May 5 14:47 __init__.cpython-38.pyc
-rw-r--r-- 1 root root 54294 May 5 14:47 events.cpython-38.pyc
-rw-r--r-- 1 root root 4239 May 5 14:47 processors.cpython-38.pyc
-rw-r--r-- 1 root root 2889 May 5 14:47 inspection.cpython-38.pyc
-rw-r--r-- 1 root root 2406 May 5 14:47 types.cpython-38.pyc
-rw-r--r-- 1 root root 1976 May 5 14:47 schema.cpython-38.pyc
-rw-r--r-- 1 root root 6569 May 5 14:47 log.cpython-38.pyc
# 改修後
$ ls -ltr ~/.virtualenvs/mydevpython38nottrim/lib/python3.8/site-packages/sqlalchemy/__pycache__/
total 116
-rw-r--r-- 1 root root 11921 May 5 14:11 interfaces.cpython-38.pyc
-rw-r--r-- 1 root root 4260 May 5 14:11 __init__.cpython-38.pyc
-rw-r--r-- 1 root root 17049 May 5 14:11 exc.cpython-38.pyc
-rw-r--r-- 1 root root 48761 May 5 14:11 events.cpython-38.pyc
-rw-r--r-- 1 root root 4242 May 5 14:11 processors.cpython-38.pyc
-rw-r--r-- 1 root root 2836 May 5 14:11 inspection.cpython-38.pyc
-rw-r--r-- 1 root root 2409 May 5 14:11 types.cpython-38.pyc
-rw-r--r-- 1 root root 1979 May 5 14:11 schema.cpython-38.pyc
-rw-r--r-- 1 root root 6476 May 5 14:11 log.cpython-38.pyc
その他
ここまで書いておいてうまくいったように見えるのですが、一点問題がありまして、CPythonビルドでmake
コマンド実行してビルド&テストする際にdoctestのテスト(8/420)がコケます。
なぜこのテストが失敗するかまでは調べられていません。
doctest用のコード(テキスト)を整形する際にインデントありのdocstringに依存する処理があったりするんでしょうか...?調査した結果を追記しようと思います。
doctestのテストが失敗する件の調査(2019/05/05追記)
doctestのテストがエラーになる件を調べてみました。
以下が発生したエラーです。
$ make
:
:
./python -m test.regrtest --pgo || true
Run tests sequentially
0:00:00 load avg: 1.13 [ 1/421] test_grammar
0:00:00 load avg: 1.13 [ 2/421] test_opcodes -- test_grammar passed
0:00:00 load avg: 1.13 [ 3/421] test_dict -- test_opcodes passed
0:00:01 load avg: 1.13 [ 4/421] test_builtin -- test_dict passed
0:00:02 load avg: 1.13 [ 5/421] test_exceptions -- test_builtin passed
0:00:03 load avg: 1.12 [ 6/421] test_types -- test_exceptions passed
0:00:03 load avg: 1.12 [ 7/421] test_unittest -- test_types passed
0:00:09 load avg: 1.11 [ 8/421] test_doctest -- test_unittest passed
**********************************************************************
File "/root/work/cpython/Lib/test/test_doctest.py", line 1227, in test.test_doctest.test_DocTestRunner.optionflags
Failed example:
doctest.DocTestRunner(verbose=False).run(test)
# doctest: +ELLIPSIS
Expected:
**********************************************************************
File ..., line 2, in f
Failed example:
print(1, 2, 3)
Expected:
1 2
3
Got:
1 2 3
TestResults(failed=1, attempted=1)
Got:
**********************************************************************
File "/root/work/cpython/Lib/test/test_doctest.py", line 2, in f
Failed example:
print(1, 2, 3)
Expected:
1 2
3
Got:
1 2 3
TestResults(failed=1, attempted=1)
**********************************************************************
1 items had failures:
1 of 65 in test.test_doctest.test_DocTestRunner.optionflags
***Test Failed*** 1 failures.
test test_doctest failed
これはやはりインデントがあることを前提にテストが書かれているのでは...
Lib/test/test_doctest.py
のL.1227を見てみると、以下のようなコードになっています。
The NORMALIZE_WHITESPACE flag causes all sequences of whitespace to be
treated as equal:
>>> def f(x):
... '>>> print(1, 2, 3)\n 1 2\n 3'
>>> # Without the flag:
>>> test = doctest.DocTestFinder().find(f)[0]
>>> doctest.DocTestRunner(verbose=False).run(test)
... # doctest: +ELLIPSIS
**********************************************************************
File ..., line 2, in f
Failed example:
print(1, 2, 3)
Expected:
1 2
3
Got:
1 2 3
TestResults(failed=1, attempted=1)
インデントを削る際に1行目は無視するので、2行目以降の空白から一番小さいインデントを判断して削除する空白の数を決めます。
この場合、最終行の3の前に1つの空白があるので、これを削るべき空白と判断して手前の2行の先頭空白を削除してしまいます。結果、テストが失敗することになります。
この動きを変えるとなると互換性の問題が出そうなので、こういう感じのdoctestがあると対応が難しそうですね...
検証としては一旦ここまでかなと思います。