scheme.rs

scheme.rs

前回、scheme.rsをclippyて言う厳しい査察官の監査を受けた。その結果、エラーが発生して、操業停止に追い込まれた。

 pub fn run(env: &EnvRef) {
     let mut i = 0;

     loop {
         let mut line = String::new();
-        io::stdout().write(b"scheme.rs> ").unwrap();
+        io::stdout().write_all(b"scheme.rs> ").unwrap();
         io::stdout().flush().unwrap();
         io::stdin().read_line(&mut line).unwrap();

で、上記のように修整して、やっとエラーは無くなった。この違いは何?

Trait std::io::Writeを見ても、大した違いがあるようには思えないんだけど。。。重箱の隅を突いて難癖をつけてるとしか思えん。

write_all なんで、全てのデータがDisplayに送られ(表示され)るかと期待。次の行にあるflushを無効にしてみた。結果、期待外れだった。

前回やったメモリーを食い潰すやつ、goshで走らせると

sakae@deb:/tmp/scheme.rs/src$ gosh
gosh$ (define (ever l) (ever (cons 3.1415926 l)))
ever
gosh$ (ever '())
 :
GC Warning: Failed to expand heap by 16777216 bytes
GC Warning: Failed to expand heap by 262144 bytes
GC Warning: Out of Memory! Heap size: 2690 MiB. Returning NULL!
out of memory (8).  aborting...

確かに食い潰してる。by debian(32Bit). 64Bit機だと、いきなりkilledってなる。カーネルが、怒って問答無用にプロセスを殺すんだな。挙動が違うってのは、困ったちゃんであります。

でも、同じ亊をscheme.rsでやっても、さっぱりメモリー消費が進まない。by top command。set命令が用意されてるとの亊なんで、下記で実験。

scheme.rs> (define l '())
scheme.rs> (define (ever) (set! l (cons 3.14159 l)) (ever))
scheme.rs> (ever)

さっぱり消費が進まない。

top - 05:57:40 up  1:08,  5 users,  load average: 1.00, 0.75, 0.46
Tasks: 104 total,   2 running, 102 sleeping,   0 stopped,   0 zombie
%Cpu(s): 82.7 us, 17.3 sy,  0.0 ni,  0.0 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
MiB Mem :   3804.6 total,   3270.2 free,    103.6 used,    430.8 buff/cache
MiB Swap:   1024.0 total,    980.7 free,     43.3 used.   3291.3 avail Mem

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
 5091 sakae     20   0   12108   6588   3160 R  99.7   0.2   5:44.12 scheme-rs
  :

何でかな? goshだと、1分もかからないうちに、メモリーを食い潰してるね。scheme.rsは、極めて、のんびりムードなのか。それともrustの肝であるGC不要機能が働いて、メモリーを消費しないようになってる?

trace

ちょいとevalに改造を加えて、入口と出口でS式を表示するようにしてみた。gitから落したやつは、整形されていなかったので、整形してからの修整。

--- /home/sakae/scheme.rs/src/evaluator.rs      2022-02-03 06:57:22.370245952 +0900
+++ src/evaluator.rs    2022-02-03 07:10:46.490399558 +0900
@@ -31,14 +31,17 @@
 pub fn eval(sexpr_: &SExpr, env_: &EnvRef) -> SResult<SExpr> {
     let mut sexpr = sexpr_.clone();
     let mut env = env_.clone_ref();
-
+    println!("==> {}", sexpr);
     loop {
         match sexpr {
             SExpr::Atom(Token::Symbol(x)) => return env.get(&x),
             x @ SExpr::Atom(_)
             | x @ SExpr::Procedure(_)
             | x @ SExpr::Port(_)
-            | x @ SExpr::Unspecified => return Ok(x),
+            | x @ SExpr::Unspecified => {
+                println!("<== {}", x);
+                return Ok(x);
+            }
             list @ SExpr::DottedList(_, _) => {
                 fn flatten(list: SExpr) -> SExprs {
                     match list {
@@ -96,7 +99,10 @@

                         match procedure {
                             SExpr::Procedure(proc) => match proc {
-                                ProcedureData::Primitive(x) => return x.apply(args),
+                                ProcedureData::Primitive(x) => {
+                                    println!("<== {}", &symbol);
+                                    return x.apply(args);
+                                }
                                 ProcedureData::Compound(x) => {
                                     env = x.build_env(args)?;
                                     sexpr = *x.body;

複数の場所から return で抜けているけど、全部は網羅していないと言う、中途半端版です。

scheme.rs> (+ 123 456)
==> (+ 123 456)
<== +
==> 123
<== 123
==> 456
<== 456
$0 = 579

プロンプトが出て来るまでに、かなり沢山のS式が評価されてる。これは嬉しい?副作用かな。 これらは、どこに定義されてる? ざっと見scmファイルなんて無かったぞ。

調べたら、いかにもって所に有った。primitives/prelude.rs

pub const PRELUDE: &'static str = "
;; some functions are from:
;; https://en.wikibooks.org/wiki/Write_Yourself_a_Scheme_in_48_Hours/Towards_a_Standard_Library

(define (id x) x)
  :
(define (call-with-input-file str proc)
  (define f (open-input-file str))
  (proc f)
  (close-port f))
";

よくやる手であります。

why slow ?

上でやった、メモリー食い潰しプログラムが、ちゃんと動いているか、検証してみる。 動作を分解してみた。

ob$ cat ever.scm
(define l '())
(define cnt 0)

(define (cs)
  (set! l (cons cnt l))
  (set! cnt (1+ cnt)))

(define (ever)
  (and (= (modulo cnt 1000) 0) (println cnt))
  (cs)
  (ever))

(cs) (println l)
(cs) (println l)
(cs) (println l)
(ever)

csって関数は、cellを一つ食い潰し、モニター用のカウンターをインクリメントする。everは、カウンターを1000毎に報告しつつ、永久にcellを消費。schemeでのループは、再帰で実現。

走らせると、csでちゃんとメモリーが消費されてるか、数ステップ実行。それから永久ループに突入。

ob$ cargo r ever.scm
(0)
(1 0)
(2 1 0)
1000
2000
3000
 :

極めてのんびりムードです。何処で道草をくってる? OSレベルで監視ったらtopね。

load averages:  1.64,  1.18,  0.72                     ob.localhost.jp 06:35:51
22 processes: 20 idle, 2 on processor                                  up  1:31
CPU0:  0.0% user,  0.0% nice,  0.0% sys,  1.4% spin,  0.0% intr, 98.6% idle
CPU1:  0.0% user,  0.0% nice,  0.0% sys,  0.0% spin,  0.0% intr,  100% idle
CPU2:  0.0% user,  0.0% nice,  0.0% sys,  0.0% spin,  0.0% intr,  100% idle
CPU3:  1.0% user,  0.0% nice, 99.0% sys,  0.0% spin,  0.0% intr,  0.0% idle
Memory: Real: 20M/757M act/tot Free: 1668M Cache: 242M Swap: 0K/502M

  PID USERNAME PRI NICE  SIZE   RES STATE     WAIT      TIME    CPU COMMAND
21771 sakae     64    0   12M   10M onproc/3  -         6:22 100.05% scheme-rs

CPU3が担当してるみたいだけど、ほとんとがシステム・プロセスで走ってます。本当か、timeコマンドでも確認する。

ob$ time cargo r ever.scm
  :
34000
35000
^C    5m19.97s real     0m07.48s user     5m12.56s system

5分走っても、ユーザー側では7秒しか動いていない。後はカーネル側だ。一体何やってるの? ソースを精査したら、解明出来る? まて、consが巡り巡って、カーネル呼出になっていないか?

csの中でのconsを止めて、単なる数値演算に変更してみた。(set! l (* cnt 3))

723000
^C    2m22.69s real     2m18.90s user     0m03.68s system

劇的にシステム呼出が減ったな。罪はconsに有りって結論ってか仮定が出来た。結論にしちゃうと、そこで進歩が止ってしまうからね。

ファイル構成について

って亊で、全体を見回す。main.rsが有って、それから、lib.rsが表面に出て来てる。 primitivesやutilsってdirの中には、mod.rsなんてのがおいてある。 これらの使い分けが、よー解らん。 調べてみる。こういうのは、実地が一番だな。

Rustのmodule完全に理解した とか、 Rustのモジュールの使い方 2018 Edition版で、 ぴったりの説明っぽいのに出会った。

この説明に照し併せてscheme.rsを見ると、mod.rsがしきりに出て来て、2015年式の書きかただと分る。stdの中でも、mod.rsが多数でてきて、そこに大事なコードが埋められている亊が多い。mod.rsと馬鹿にしないで、目を通しておこう。

cons

consの在処は、src/primitives/list.rsの中。それを如実に示しているのが、gdbでBPを置いた時。

Num     Type           Disp Enb Address    What
1       breakpoint     keep y   0x171cdb30 in scheme_rs::primitives::list::cons::h564b37294f0fa426 at src/primitives/list.rs:6

実際にconsの中身に分け入ってみようとしても、付け入る隙が無かった。

at debian

しょうがないので、consを外側から観察。cons一発に対してmallocを律儀に実行してるのではなかろうか。mallocのカーネル側挙動ったら、K&RのC言語教本に載ってたあれだか。 ならば、straceしてみよう。

sakae@deb:/tmp/scheme.rs/target/release$ strace -o LOG ./scheme-rs ever.scm

何となくリナで動かしていたんでstraceです。 ログを眺めてみる。

278write(1, "1000", 4)                     = 4
279write(1, "\n", 1)                       = 1
280brk(0xf4d000)                           = 0xf4d000
281brk(0xf22000)                           = 0xf22000
282brk(0xf4d000)                           = 0xf4d000
 :
851mmap2(NULL, 266240, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0     ) = 0xb7bc9000
852munmap(0xb7bc9000, 266240)              = 0
 :
942brk(0xf37000)                           = 0xf37000
943brk(0xf77000)                           = 0xf77000
944write(1, "2000", 4)                     = 4
945write(1, "\n", 1)                       = 1

冒頭につけた数字は、行番号。これを見ると、完全にcons一発についてシステムコールのbrkが発行されてる訳ではないけど、高頻度でカーネルを呼び出している亊が分る。加えて、mmap2とかも割り込んでいるし。これじゃ、重いわけだ。

ちなみに、brkってのは、

SYNOPSIS
       #include <unistd.h>

       int brk(void *addr);

       brk()  sets the end of the data segment to the value specified by addr,
       when that value is reasonable, the system has enough  memory,  and  the
       process does not exceed its maximum data size (see setrlimit(2)).

ログをbrkで検索してくと、最初はbrk(NULL)を渡して、システム側からアドレスを教えてもらっている。後は、小まめにdata segmentを増減させてる。rust側では、allocの仕業なんだろうね。

もう少しallocが利口になって、sbk発行の頻度を下げて欲しいぞ。いや、そんなのは、ユーザーの責任だろう。Box型でドーンとメモリーを確保。それをユーザーがプールしながら使い倒す。なにそれ。結局自前でGCしろってなっちゃうじゃん。すると、rustの効用が失われてしまう。

この世の中に、銀の弾 なんて、ないのさ(by 人月の神話)。

at OpenBSD

既に結論は出てるけど、BSD野郎としては、確かめずにはいられない。 target/release の中で、直接バイナリーを指定します。

ob$ ktrace -tc ./scheme-rs ever.scm
(0)
(1 0)
(2 1 0)
1000
2000
3000
^C

後は、kdumpすれば、ktrace.outをエンコードして、結果が出て来る。

73913 scheme-rs CALL  write(1,0x1f8233dd6a6,0x1)
73913 scheme-rs RET   write 1
73913 scheme-rs CALL  mmap(0,0x16000,0x3<PROT_READ|PROT_WRITE>,0x1002<MAP_PRIVATE|MAP_ANON>,-1,0)
73913 scheme-rs RET   mmap 2174107910144/0x1fa32ee0000
73913 scheme-rs CALL  mquery(0x1fa32ef6000,0x15000,0x3<PROT_READ|PROT_WRITE>,0x1012<MAP_PRIVATE|MAP_FIXED|MAP_ANON>,-1,0)
73913 scheme-rs RET   mquery 2174108000256/0x1fa32ef6000
73913 scheme-rs CALL  mmap(0x1fa32ef6000,0x15000,0x3<PROT_READ|PROT_WRITE>,0x1002<MAP_PRIVATE|MAP_ANON>,-1,0)
73913 scheme-rs RET   mmap 2174108000256/0x1fa32ef6000
73913 scheme-rs CALL  munmap(0x1faf44cb000,0x2b000)
73913 scheme-rs RET   munmap 0

こんな感じにmmapが使われているようだ。ざっと見、cons一発にmmapがもろに対応してる風だ。これじゃ、時間がかかるの、頷けるな。

at FreeBSD

いきがかり上FreeBSDでも確認した。mmapは顕著に使われていなかった。従って、グローバルに観測出来た、カーネルの呼出具合も、下記のように小さい。

42000
43000
44000
^C

real    1m1.784s
user    1m1.368s
sys     0m0.169s

それにも拘わらず、ドンドコとメモリーが消費される風でもない。上のモニターの数値表示の時間間隔が、段々と遅くなって行く。これは、何か無駄してる公算が大だ、な。

ソース目視

consのソースを眺めてる。なんたって、gdbで分け入っていかれないからね。

入ってすぐの所で、引数が2つ有る亊を確認しつつ、引数をx,xsに分配。 続いて、マッチ式で確認・分岐させてる。haskellを彷彿させるな。

ListとDottedListって型は分るんだけど、その他のマッチ、y って何だろう? 考えても解らないので、コンパイラーに教えてもらおう。そう、y => SExpr::DottedList( … ) をコメントにしてあげるのさ。

error[E0004]: non-exhaustive patterns: `Atom(_)`, `Procedure(_)`, `Port(_)` and 1 more not covered
  --> src/primitives/list.rs:8:24
   |
8  |       let result = match xs {
   |                          ^^ patterns `Atom(_)`, `Procedure(_)`, `Port(_)` and 1 more not covered

ほー、そういう亊ですかい。全ての場合を尽していないとな。parser.rsに定義してある

pub type SExprs = Vec<SExpr>;

#[derive(Debug, Clone, PartialEq)]
pub enum SExpr { 16 implementations
    Atom(Token),
    List(SExprs),
    DottedList(Vec<SExpr>, Box<SExpr>),
    Procedure(ProcedureData),
    Port(PortData),
    Unspecified,
}

これを根拠に、お前は抜けてるって言ってきたんだな。

gdbは、丁寧に使え

gdbが使えないって言った。そう、consでbreakした後、stepすると、すぐにconsを抜けちゃうんで、使えねー奴だなとなってたんだ。

心を入れ替えて、matchのそれぞれの分岐先にBPを置いてみた。

(gdb) info breakpoints
Num     Type           Disp Enb Address    What
1       breakpoint     keep y   0x004daf5f in scheme_rs::primitives::list::cons at src/primitives/list.rs:6
        breakpoint already hit 2 times
2       breakpoint     keep y   0x004db39c in scheme_rs::primitives::list::cons at src/primitives/list.rs:10
        breakpoint already hit 1 time
3       breakpoint     keep y   0x004db45c in scheme_rs::primitives::list::cons at src/primitives/list.rs:14
4       breakpoint     keep y   <MULTIPLE>
        breakpoint already hit 2 times

そして実行

scheme.rs> (cons 111 (list 222 333 444))

Breakpoint 1, scheme_rs::primitives::list::cons (args=...)
    at src/primitives/list.rs:6
6           let (x, xs) = args.evaled()?.own_two()?;
(gdb) c
Continuing.

Breakpoint 2, scheme_rs::primitives::list::cons (args=...)
    at src/primitives/list.rs:10
10                  xs.insert(0, x);
(gdb) n
11                  SExpr::List(xs)
(gdb) p x
$4 = scheme_rs::parser::SExpr::Atom(scheme_rs::lexer::Token::Integer(111))
(gdb) p xs
$5 = Vec(size=4) = {
  scheme_rs::parser::SExpr::Atom(scheme_rs::lexer::Token::Integer(111)),
  scheme_rs::parser::SExpr::Atom(scheme_rs::lexer::Token::Integer(222)),
  scheme_rs::parser::SExpr::Atom(scheme_rs::lexer::Token::Integer(333)),
  scheme_rs::parser::SExpr::Atom(scheme_rs::lexer::Token::Integer(444))}

xs.insert(0,x)は、xsってやつのindex番号0(すなわち先頭)にxを入れ込めって指示。11行目で、その結果をS式としてmatchの結果とせよ。

(gdb) c
Continuing.
$5 = (111 222 333 444)

で、これが、最終的な回答だ。

gdbは、matchを一つの実行単位としちゃうから、内側までは(今回のように指示しないと)入り込まないのね。よーく、挙動が分かりましたよ。

consの真実

これらの状況証拠、pub type SExprs = Vec<SExpr>; と、gdbでの観察結果より、consの実体は、Vecに格納されてる奴の頭に、インサートだ。そのインサートは、先頭になされる。場所をあけてから、データを入れる亊になる。今迄有ったデータを移動。これにO(n)の時間がかかるんだな。なお、nは、リストの長さだ。

すなわち、リストが長くなればなる程、一回のconsの時間がかかるって亊だ。今迄のconsの実装とは、全く違ったものになってる。青天の霹靂だ。consはただの方便と言ったら言い過ぎ?

で、肝心のinsertの実装は、src/alloc/vec/mod.rs にある。

unsafe {
    {
        let p = self.as_mut_ptr().add(index);
        ptr::copy(p, p.offset(1), len - index);
        ptr::write(p, element);
    }
    self.set_len(len + 1);
}

時間がかかる部分はcopy(src, dst, size) て部分。場所を空けて、データを必要個数分コピー(移動)して、空いた所に新しいデータを突っ込むって動作になってる。

clippyはAI?

OpenBSDに入れてる

ob$ cargo-clippy -V
clippy 0.1.55

を使って、scheme.rsを監査すると、鋭い指摘が追加で出て来た。冒頭にあげた修整版のやつは、debianの最新版。それでは指摘が無かったから、OpenBSD独自のものか?

error: strict comparison of `f32` or `f64`
   --> src/primitives/numeric.rs:175:16
    |
175 |             if result.trunc() == result {
    |                ^^^^^^^^^^^^^^^^^^^^^^^^ help: consider comparing them with
in some margin of error: `(result.trunc() - result).abs() < error_margin`
    |
   ::: src/primitives/mod.rs:70:26
    |
70  |         "atan"        => call_float_fun!(atan, atan2),
    |                          ---------------------------- in this macro invocation
    |
    = note: `f32::EPSILON` and `f64::EPSILON` are available for the `error_margin`
    = help: for further information visit https://rust-lang.github.io/rust-clipp
y/master/index.html#float_cmp
    = note: this error originates in the macro `call_float_fun` (in Nightly buil
ds, run with -Z macro-backtrace for more info)

エラーを検出してるのは、禁断のマクロ関数内。1つか2つの引数を取る。1つの場合にもエラーになり、その引数はacosだ。acosなんて知らないぞ。まずは敵を調べる。

ob$ irb
irb(main):001:0> Math.acos(0) * 2
=> 3.141592653589793

で、問題のマクロの一部分

macro_rules! call_float_fun(
    ($e: ident) => {
        |args| {
            let num = args.evaled()?.own_one()?;
            let result = num.into_float()?.$e();
            if result.trunc() == result {
                Ok((result as i64).into())
            } else {
                Ok(result.into())
            }
        }
    };

関数を入力された数値に適用。得られた結果の少数部分を削除したものと、元々の結果が一致したら、少数点は止めて、整数でいいじゃんって言う親切設計(の積もり)なんだな。

2.000000000000001 なら、2でいいじゃん。当該のコードでは、それが実現出来ていないよって指摘。

clippyはAI機能が入ってて、人間より頭が良いんです。企業の広報の方なら、大々的に宣伝したい所だ。なんで、突然、広報なんて言葉が出て来たか?

それは、今読んでいる本の影響なのさ。もし幕末に広報がいたらって本。大政奉還のプレスリリースを書いてみた なんて、そそる副題がついている。歴史上の数々の事件を題材に、プレスリリースを書いてみせてくれて、とっても面白い。日経BPから出てるんで、読んでみるといいよ。

ああ、脱線したな。clippyが提示した解決法は、実は中途半端だ。本当のAIにはなっていないね。字面をいじっているだけ。1.999999… を 2 にはしてくれない。

広報に騙されるな。ちゃんと自分の頭で考えよう。


This year's Index

Home