nim-lang

Activity

Stars and mentions · Last 30 days, Oct 6 – Nov 4

GitHub stars0
Mentions3
01234Oct 6Oct 20Nov 4
UTC days · repositories nim-lang owns0 repositories

Top repositories

nim-lang’s repositories ranked by new forks, as on trending.

Loading

Contributes to

Repositories outside nim-lang’s account where nim-lang opened or reviewed pull requests in the past 365 days.

Who noticed nim-lang

Everyone who starred or forked nim-lang’s repositories since the archive began in 2011.

Notable accounts

Ordered by the stars their own repositories have received.

Biggest fans

Accounts that starred the most of these repositories.

Breakout weeks

Each repository’s best week of new stars, with the release published around it.

Mentions

3posts
Nov 50 posts
  1. Nim の HCR で変異検査は速くならなかった。変異をまとめてビルドする形にし、HCR が効く 3 つの場面を測った これまでの記事(1 本目、2 本目)では、Nim のホットコードリロード(HCR)で、動いている MCP サーバに道具を足しました。3 本目では、読み込み直しで大域変数の型が変わったときに、黙って壊れた値を読んでいた問題を直しました。HCR を残すかどうかの議論(nim-lang/RFCs#573)は、2026-10-13 に、HCR を残す方向で閉じました。残ることになった HCR がほかにどんな場面で使えるのかを確かめたくて、この記事を書いています。 今回は、別の場面で HCR を試しました。テストの環境、とくに変異検査です。変異検査は、プログラムを 1 か所ずつわざと壊し、テストが壊れたことに気づくかを確かめます。壊した版ごとにビルドし直すので、とても時間がかかります。HCR で作り直しを速くできれば、と考えました。 結果は、HCR はこの用途には向きませんでした。ただ、試したことで、時間がどこで使われているかが分かりました。そこから、別の方式(mutant schemata)にたどり着きました。この記事では、HCR で確かめたことと、その代わりに作った仕組みの実測を書きます。 先に結論 HCR でも、作り直しの時間はほとんど減りませんでした。 1 か所変えた後のビルドは、HCR が 1.04 秒、通常が 1.19 秒でした(10 回の中央値)。どちらでも、コンパイラは 5 万 5 千行あまりを毎回処理します。 HCR は状態を残します。変異検査はそれを嫌います。 トップレベルの let の初期値を変えても、読み込み直した後に反映されませんでした。グローバル変数の値は次の版に持ち越されました。変異検査で使うと、判定が流す順番で変わります。 代わりに、変異をまとめて 1 つの原稿に入れ、ビルドを 1 回にしました(mutant schemata)。 どの変異を使うかは、実行のたびに環境変数で選びます。1 件ずつ新しいプロセスで流すので、状態は持ち越されません。 3 つのライブラリで、実時間が今までの方式の 0.10〜0.30 倍になりました。 判定は、CPU 時間の上限すれすれの 1 件を除いて、全件が今までと同じでした。その 1 件は、今までの方式どうしでも流すたびに判定が変わります。 HCR が効くのは、状態を残したい場面です。 3 つの場面で測りました。索引を作るのに 25 秒かかる検索では、順位の手続きの変更 1 回が 25.4 秒から 1.37 秒になりました。60 秒回したシミュレーションに測る量を足すのは、52 秒が 2.2 秒になりました。サーバでは、接続もセッションも切らずに返事を直せました。ただし、HCR でビルドした版は、シミュレーションそのものが 2 割あまり遅くなりました。 変異検査の時間はどこで使われるか 変異検査の道具(ここでは自作の nim-muter。ユースケースでのテストがおおむね済んだら公開する予定です)は、次を繰り返します。 原稿を 1 か所書き換える(a + b を a - b に、< を <= に、など)。 ビルドする。 テストを流す。テストが落ちれば、その変異は「検出された」。通れば「生き残った」。 ふつうは、書き換え 1 件ごとにビルドが 1 回あります。たとえば自作のライブラリ nim-kill-matrix(500 行)では、変異が 324 件あり、2 つ並べて流して 1,374 秒かかりました。1 件あたりの時間(ビルドとテスト)の中央値は 8.4 秒です。一方、ビルド済みのものでテストだけを流すと、中央値は 0.1 秒でした。時間のほとんどはビルドです。 なので、速くするには、ビルドを速くするか、ビルドの回数を減らすかです。前者には HCR が効くかもしれない、と考えました。 HCR で確かめた devel(00c876497、2026-10-20)の macOS arm64 で、小さなプログラムを使いました。手順は、HCR(--hotCodeReloading:on)でビルドして動かし、logic.nim を書き換えてビルドし直し、go を作って読み込み直させる、です。 書き換える側のモジュールです。 logic.nim var counter* = 0 let limit* = 4096 proc classify*(n: int): string = inc counter if n < 100: "small" else: "large" メインのモジュールは、値を出し、go というファイルができたら読み込み直して、もう一度出します。 main.nim import std/[hotcodereloading, os] import logic for round in 0 .. 1: echo "round ", round, ": classify(100)=", classify(100), " limit=", limit, " counter=", counter flushFile stdout if round == 0: while not fileExists("go"): sleep 50 performCodeReload() 動いている間に、logic.nim の < を <= に(変異検査の ROR にあたる書き換え)、limit を 30000 に変えてビルドし直し、読み込み直させました。 round 0: classify(100)=large limit=4096 counter=1 round 1: classify(100)=small limit=4096 counter=2 確かめたこと 結果 変異検査にとっての意味 関数の中身の書き換え 反映された(large → small) 関数の中の変異は試せる トップレベルの let の初期値 反映されない(4096 のまま) 初期値の変異は、テストが検出できるのに「生き残った」と誤って報告される グローバル変数 次の版に持ち越された(1 → 2) 前の変異の状態が次に混ざり、判定が流す順番で変わる 1 か所変えた後のビルド HCR 1.04 秒、通常 1.19 秒(10 回の中央値) ほとんど変わらない let が反映されないのは、文書のとおりです。HCR はトップレベルのコードを読み込み直しのたびには実行しません。状態の持ち越しも、HCR の目的そのものです。1 本目の記事の MCP サーバでは、これが長所でした。 ビルドの時間がほとんど減らないのは、コンパイラの前段が、変わったモジュールだけでなくプログラム全体を毎回処理するからです。この小さなプログラムでも、ビルドのたびに 55,444 行を処理していました(標準ライブラリを含む)。通常のビルドでも、変わっていないモジュールの C はキャッシュから使われます。HCR との差は 0.15 秒ほどでした。変わったモジュールのライブラリだけを作り直せばよく、実行ファイル全体をリンクし直さない分だと見ていますが、内訳までは測っていません。 ほかにも、変異検査に使うには引っかかる点がありました。 非推奨の警告が出ます。 devel は --hotCodeReloading:on に hotCodeReloading is deprecated, see RFCs#573 を出します。RFC は HCR を残す方向で閉じましたが、2026-10-21 の devel(04f54a92e)でも、この警告はまだ出ます。警告 0 件を合格の条件にしているプロジェクトでは通りません。 型を変えると読み込み直せません。 変異が型を変えることはあまりありませんが、型の合わない変異がビルドを止めたときの扱いは別に要ります。 メインのモジュールは差し替えられません。 テストのファイルがメインなので、変異を入れる側はライブラリのモジュールに限られます。これは変異検査では困りません。 変異検査が欲しいのは「毎回まっさらな状態で、書き換えた版を流す」ことです。HCR は「状態を残したまま、中身を替える」道具で、目的が逆でした。 代わりに: 変異をまとめて 1 回でビルドする 試して分かったのは、時間を食っているのがビルドの回数だということです。そこで、回数そのものを減らす方式を作りました。古くからある方式で、mutant schemata と呼ばれます(Untch、Offutt、Harrold、ISSTA 1993)。JavaScript などの変異検査の道具 Stryker も、mutation switching という名前で同じことをしています。 すべての変異を 1 つの原稿に入れ、各箇所を実行時の分岐にします。たとえば result = a + b に 2 つの変異(7 番と 8 番)があるとき、こう書き換えます。 result = (if nimMuterActive() notin [7, 8]: a + b elif nimMuterActive() == 7: a - b elif nimMuterActive() == 8: a * b else: raiseAssert "nim-muter: a schema chose no branch") nimMuterActive() は、環境変数 NIM_MUTER_ACTIVE を起動時に 1 回だけ読んで返します。ビルドは 1 回で、テストを流すときに NIM_MUTER_ACTIVE=7 のように選びます。1 件ずつ新しいプロセスで流すので、HCR で起きた状態の持ち越しは起きません。トップレベルの let の初期値も、分岐の中に入るので書き換えられます。 影のコピーにだけ置くモジュール(利用者の原稿には入らない。コメントは訳した) {.used.} import std/[os, strutils] proc readActive(): int = let text = getEnv("NIM_MUTER_ACTIVE") if text.len == 0: return -1 try: parseInt(text) except ValueError: quit("nim-muter: NIM_MUTER_ACTIVE is not a mutant id: " & text, 2) let active = readActive() proc nimMuterActive*(): int {.noSideEffect, gcsafe, raises: [].} = func の中の変異からも呼ぶ。値は、プログラムのコードが動く前に一度だけ 読み、その後は変わらない。 {.cast(noSideEffect), cast(gcsafe).}: result = active HCR と比べると、次のようになります。 HCR mutant schemata 何を差し替えるか 動いているプロセスのコード 実行ファイルの中の分岐(起動のときに選ぶ) ビルドの回数 書き換えのたびに 1 回 まとめて 1 回(ビルドできない変異があれば、分けて数回) 状態 持ち越す 毎回新しいプロセス トップレベルの初期値 反映されない 書き換えられる 向く用途 状態を保ったままの試行錯誤 同じテストを、たくさんの版で繰り返す検査 分岐にできない場所 const の値、型、when の条件、case の of の値などは、コンパイラが評価します。実行時の分岐は書けません。こうした場所の変異は、今までどおり 1 件ずつビルドします。 分岐にできる場所でも、1 つの原稿に入れるとビルドできない変異があります。そのときは、コンパイラのエラーから原因の変異を 1 件選び、その変異だけと残りとに分けてビルドし直します。選んだ変異が本当に原因かは、それだけでビルドして確かめます。選べなければ、半分に割ります。 実測 自作の 3 つのライブラリで、今までの方式(以下 classic)と、まとめる方式(以下 schemata)を比べました。macOS arm64、Nim 2.2.12、2 つ並べて流しました。 対象 変異 classic schemata 比 ビルドの回数 nim-golden 122 314 秒 76 秒 0.24 倍 30 nim-kill-matrix 324 1,374 秒 131 秒 0.10 倍 106 nim-propcheck 188 294 秒 87 秒 0.30 倍 70 3 つとも、同じ時間帯に続けて流しました。機体にはほかの作業の負荷があり(load average 9〜28)、classic の時間は負荷の低いときより長く出ています(golden は、同じ日の負荷の低い時間帯には 112 秒でした)。比べるのは、同じ時間帯に流した組どうしにしています。 ビルドの回数は、2 つ並べて流すために、同じ原稿を 2 回ずつビルドした回数を含みます。 判定は、634 件のうち 633 件が classic と同じでした。 違った 1 件は、nim-propcheck の 147 行の変異です。テストが CPU 時間をおよそ 2 秒使い、上限の 2 秒すれすれでした。 今回は classic が「検出」、schemata が「時間切れ」でした。 別の日には、classic が「時間切れ」、schemata が「検出」で、向きが逆でした。 何も重ねずに 6 回流すと、6 回とも「検出」でした。 classic どうしでも判定が変わるので、まとめる方式の誤りではありません。上限の近くでは判定が揺れる、という別の問題です。 つまずいたこと 作る途中で、まとめた原稿がビルドできない場面にいくつも当たりました。Nim でこの方式を作るときの注意として書いておきます。 --warningAsError:UnusedImport で落ちる untyped の引数(astToStr など)の中にしか変異がない原稿では、nimMuterActive を import しても、コンパイラは使っていないとみなします。警告を誤りとして扱う設定だと、ビルドが落ちます。classic では、どの変異もビルドできるのにです。選ぶ関数のモジュールに {.used.} を付けました。 if 式の型は、最初の枝で決まる 最初は、元のコードを最後の枝(else)に置いていました。すると、型の合わない変異があるとき、コンパイラは元のコードの枝を誤りの場所として示しました。元の枝の中には、入れ子になった別の変異の分岐があり、関係のない変異を原因として選んでいました。元のコードを最初の枝に置くと、型の合わない変異は、その変異自身の枝の行で報告されます。 代入される側は包めない inc result.tests[at].kills の result.tests[at] のように、var 引数の位置にある式を if 式で包むと、ビルドできません。if 式は代入される側になれないからです。classic ではビルドできる変異なので、classic に回します。 誤りが、原因より上の行に出る nim-kill-matrix では、直す前の形でビルドが 594 回もかかっていました。原因を調べると、1 件の変異でした。 let was = try: readRecord(before) except KillMatrixError as e: refuse("before report: " & e.msg) refuse(...) を消す変異を入れると、except の枝が値を持たなくなり、try 全体が値を持たなくなります。コンパイラは、except の枝ではなく、その上の readRecord(before) の行を誤りとして示します(expression 'readRecord(before)' is of type 'Record' and has to be used)。 「誤りの行から上で、いちばん近い変異を原因とする」という規則だったので、前の proc にある関係のない変異を 1 件ずつ原因として選び、そのたびにビルドし直していました。119 件を外しても原因には届かず、最後は束を半分ずつに割って見つけていました。 選ぶ範囲を、誤りの行を含むトップレベルの文(この場合は proc compare)の中に限りました。その中で選べなければ、その proc の変異だけを別にしてビルドし直します。これで、ビルドは 594 回から 106 回に、実時間は 1,227 秒から 131 秒になりました。判定は変わっていません。 原因を選んだら、それだけでビルドして確かめる、という仕組みが最初からありました。なので、判定は最初から正しく、ずれていたのは時間だけでした。遅さの原因は、推測が当たったかどうかではなく、推測する範囲でした。 HCR が効く場面を測った 変異検査には向きませんでしたが、裏返すと、HCR が効く場面の条件が見えます。「状態を残したい」「準備が重い」「関数の中身だけを替える」場面です。そうした場面を 3 つ作り、1 か所の変更を、動いているプロセスに HCR で読み込ませる場合と、ビルドし直して起動し直す場合とで比べました。 どれも、同じ devel の macOS arm64 で、-d:release でビルドしました。メインのモジュールは読み込み直さず、変えるのはもう 1 つのモジュールだけです。コードは、時間を測る Python のスクリプトを除いて、記事の末尾の付録に載せました。 1. 重い索引を作った後の、検索の順位の調整 .nim のファイルを全部読んで索引を作り、BM25 で順位を付ける検索です。索引を作るモジュールと、順位を付けるモジュール(rank.nim)を分け、rank.nim の係数 k1 を 1.2 と 1.5 で交互に変えて、同じ 3 つの問い合わせに答え直させました。変えるたびに上位の点数が 22.69 と 21.32 で入れ替わるので、新しい係数で答えていることも確かめられます。 索引を作る対象 索引を作る時間 HCR(1 回の変更) 起動し直し(1 回の変更) Nim のリポジトリ(6,421 ファイル、41 MB) 1.0〜1.7 秒 1.01 秒 2.50 秒 社内のコード一式(85,844 ファイル、410 MB) 23〜37 秒 1.37 秒 25.4 秒 時間は、ファイルを書き換えてから、新しい順位の答えが出るまでです(5 回の中央値)。起動し直す側は、変更のたびに索引を作り直します。HCR の側は、ビルドの 0.8〜2.9 秒と、読み込み直しの 0.01〜0.2 秒だけです。社内のコード一式では、変更 1 回あたり 18 倍の差になりました(※)。 効き目は、「準備の時間」と「ビルドの時間」の比で決まります。Nim のリポジトリくらいの大きさでも、変更 1 回で 1.5 秒ずつ縮みます。係数を何十回も試す作業なら、これが積み重なります。 ※ 社内のコード一式は、測っている間もほかの作業でファイルが増えていて、ファイルの数は 85,844〜85,847 でした。 2. 長く回したシミュレーションに、後から測る量を足す 2 次元の Ising 模型(512×512、温度 2.0)を Metropolis 法で回すシミュレーションです。格子と乱数の状態はメインのモジュールが持ち、1 回の掃引と、測る量(磁化)を model.nim が持ちます。60 秒回した後で、「エネルギーも測りたい」と observe を書き換えました。 新しい量が出るまで 続きから回るか HCR 2.2 秒(ビルド 1.9 秒、読み込み直し 0.27 秒) 回る(4,129 歩目から続き、4,257 歩目でエネルギーが出た) 起動し直し 52.2 秒 0 歩目からやり直し。4,274 歩目に届くまでに 52 秒 HCR では、格子も乱数も歩数も、そのまま残りました。60 秒かけて育った磁区を、捨てずに新しい量で測れます。起動し直す側は、同じところまで回し直すのに、それまでと同じだけの時間がかかります。回した時間が長いほど、差は開きます。 ただし、HCR でビルドした版は、シミュレーションそのものが遅くなりました。 20 秒で進んだ歩数を 3 回ずつ比べると、HCR の版は 1,645〜2,454 歩、通常の版は 2,568〜2,927 歩で、平均で 2 割あまり遅れました。掃引の中で乱数の手続きを何十万回も呼ぶので、モジュールをまたぐ呼び出しが、HCR の間接の呼び出しになり、インライン化もされないのが原因だと見ています(内訳は測っていません)。今回の 60 秒の例では、この遅れを差し引いても HCR のほうが速く済みます。一方、途中で何も変えずに最後まで回すだけなら、通常のビルドのほうが速く終わります。 3. 接続とセッションを切らずに、サーバの返事を直す 行単位の TCP サーバです。接続ごとのセッション(ログイン名と合計)をメインのモジュールが持ち、要求に答える手続きを handler.nim が持ちます。クライアントは 1 本の接続で、50 ミリ秒ごとに add 1 を送り続けます。その間に、返事に要求の数も載せるよう handler.nim を変えました。3 回ずつ変えています。 新しい返事が出るまで 接続 セッション(合計) 返事の最大の間隔 HCR 1.03〜1.07 秒 切れない(0 回) 残る(40 → 78 → 117 と続く) 0.055 秒 起動し直し 1.17〜1.35 秒 変更のたびに切れる 消える(毎回 1 からやり直し) 0.19〜0.21 秒 この例では、サーバの起動が速いので、時間の差は小さく出ました。違いは状態です。HCR では、クライアントから見ると、返事の形がある時点から変わっただけで、間隔もふだんの 50 ミリ秒のままでした。起動し直すと、接続が切れ、クライアントはつなぎ直してログインし直し、合計は 1 から数え直しになりました。ログイン済みのセッションや、作りかけの状態を持つ開発中のサーバで、手で状態を作り直す手間がなくなります。1 本目と 2 本目の記事の MCP サーバも、この場面にあたります。 3 つの場面から 3 つとも、HCR は「探る」ための道具として効きました。準備や状態を残したまま、関数の中身だけを何度も替えて結果を見る使い方です。探った結果を「確かめる」のは、通常のビルドでのテストに任せます。変異検査がうまくいかなかったのは、それが「確かめる」側の仕事だったからです。 まだ測っていないこと まとめる方式は、まだ試行の段階で、nim-muter の設定で選んだときだけ動きます。 測ったのは 175〜500 行のライブラリ 3 つだけです。数千行のモジュールでは、ビルドできない変異の探し方の手間が増えるかもしれません。 HCR のビルドの時間は、小さなプログラムで測りました。大きなプログラムでは、HCR との差(リンクの分と見ています)の大きさが変わるはずです。ただ、前段がプログラム全体を毎回処理することは変わりません。 HCR が効く 3 つの場面は、どれも 1 台の macOS で、ほかの作業の負荷が少しある中で測りました(load average 2〜6)。Linux では試していません。シミュレーションの遅れは、場面によって大きさが変わるはずです。 HCR の使い道について、「この場面で効いた」「この場面では向かなかった」という例があれば、コメントに書いてもらえると助かります。 付録: 測ったコード HCR の場面ごとに、Nim のコードを載せます。どれも nim c -d:release --hotCodeReloading:on main.nim でビルドし、libnimhcr と libnimrtl を置いたディレクトリで動かします。reload.stamp に触ると読み込み直します。 検索 corpus.nim The index of the corpus. This module is not changed while the program runs, so the index it builds stays in memory across reloads. import std/[os, strutils, tables] type Posting* = tuple[doc, tf: int] Corpus* = object paths*: seq[string] lengths*: seq[int] postings*: Table[string, seq[Posting]] averageLength*: float proc tokens*(text: string): seq[string] = var word = "" for c in text: if c in IdentChars: word.add toLowerAscii(c) elif word.len > 0: result.add word word.setLen 0 if word.len > 0: result.add word proc load*(root: string): Corpus = for path in walkDirRec(root): if not path.endsWith(".nim") or "/.git/" in path: continue let doc = result.paths.len result.paths.add path.relativePath(root) var counts = initCountTable[string]() var length = 0 for t in tokens(readFile(path)): counts.inc t inc length result.lengths.add length for t, n in counts: result.postings.mgetOrPut(t, @[]).add (doc, n) var total = 0 for n in result.lengths: total += n result.averageLength = total / result.lengths.len rank.nim The ranking: the module tuned while the program runs. import std/[algorithm, math, tables] import corpus const k1 = 1.2 b = 0.75 proc score(c: Corpus; tf, df, length: int): float = let idf = ln(1.0 + (float(c.paths.len - df) + 0.5) / (df.float + 0.5)) idf * (tf.float * (k1 + 1)) / (tf.float + k1 * (1 - b + b * length.float / c.averageLength)) proc search*(c: Corpus; query: string; k = 3): seq[(string, float)] = var scores = initTable[int, float]() for t in tokens(query): let postings = c.postings.getOrDefault(t) for p in postings: scores.mgetOrPut(p.doc, 0.0) += score(c, p.tf, postings.len, c.lengths[p.doc]) var ranked: seq[(float, int)] for doc, s in scores: ranked.add (s, doc) ranked.sort(Descending) for (s, doc) in ranked[0 ..< min(k, ranked.len)]: result.add (c.paths[doc], s) main.nim Loads the corpus once, then answers the same queries after every reload. A reload happens when reload.stamp is newer than the last one. import std/[os, monotimes, strutils, times, hotcodereloading] import corpus, rank const queries = ["hot code reloading", "thread local storage", "effect tracking forbids"] proc show(c: Corpus) = for q in queries: echo "query: ", q for (path, s) in search(c, q): echo " ", formatFloat(s, ffDecimal, 2), " ", path echo "answered" flushFile stdout let started = getMonoTime() let c = load(paramStr(1)) echo "loaded ", c.paths.len, " files in ", (getMonoTime() - started).inMilliseconds, " ms" show(c) var seen = if fileExists("reload.stamp"): getLastModificationTime("reload.stamp") else: Time() while true: sleep 20 if fileExists("reload.stamp") and getLastModificationTime("reload.stamp") != seen: seen = getLastModificationTime("reload.stamp") let at = getMonoTime() performCodeReload() echo "reloaded in ", (getMonoTime() - at).inMilliseconds, " ms" show(c) シミュレーション model.nim The model and what is measured: the module changed while the simulation runs. The lattice and the random state live in main.nim and are kept. import std/[math, random, strutils] const side* = 512 temperature = 2.0 # below the critical 2.269: domains grow proc sweep*(spins: var seq[int8]; rng: var Rand) = One Metropolis sweep of the 2D Ising model, periodic boundaries. for _ in 0 ..< side * side: let x = rng.rand(side - 1) let y = rng.rand(side - 1) let s = spins[y * side + x].int let around = spins[y * side + (x + 1) mod side].int + spins[y * side + (x + side - 1) mod side].int + spins[((y + 1) mod side) * side + x].int + spins[((y + side - 1) mod side) * side + x].int let dE = 2 * s * around if dE <= 0 or rng.rand(1.0) < exp(-dE.float / temperature): spins[y * side + x] = int8(-s) proc observe*(spins: seq[int8]): string = var m = 0 for s in spins: m += s.int "magnetization=" & formatFloat(abs(m) / spins.len, ffDecimal, 4) main.nim Runs the simulation and prints what model.observe measures twice a second. A reload happens when reload.stamp is newer than the last one; the lattice, the step count and the random state carry over. import std/[os, random, monotimes, times, hotcodereloading] import model var rng = initRand(1) var spins = newSeq[int8](side * side) for s in spins.mitems: s = (if rng.rand(1) == 0: -1'i8 else: 1'i8) var step = 0 let started = getMonoTime() var lastReport = started var seen = if fileExists("reload.stamp"): getLastModificationTime("reload.stamp") else: Time() while true: sweep(spins, rng) inc step let now = getMonoTime() if now - lastReport >= initDuration(milliseconds = 500): lastReport = now echo "t=", (now - started).inMilliseconds, "ms step=", step, " ", observe(spins) flushFile stdout if fileExists("reload.stamp") and getLastModificationTime("reload.stamp") != seen: seen = getLastModificationTime("reload.stamp") let at = getMonoTime() performCodeReload() echo "reloaded in ", (getMonoTime() - at).inMilliseconds, " ms at step=", step flushFile stdout サーバ session.nim What the server keeps for each connection. Not changed while it runs. type Session* = object name*: string total*: int requests*: int handler.nim The request handler: the module changed while the server runs. import std/strutils import session proc handle*(s: var Session; line: string): string = inc s.requests let parts = line.splitWhitespace if parts.len == 2 and parts[0] == "login": s.name = parts[1] "hello " & s.name elif parts.len == 2 and parts[0] == "add": s.total += parseInt(parts[1]) "total=" & $s.total else: "unknown: " & line main.nim A line-based TCP server. It keeps every connection and its session; a reload happens between requests when reload.stamp is newer than the last one. import std/[net, os, monotimes, selectors, strutils, tables, times, hotcodereloading] import session, handler let server = newSocket() server.setSockOpt(OptReuseAddr, true) server.bindAddr(Port(parseInt(paramStr(1)))) server.listen() let events = newSelector[int]() events.registerHandle(server.getFd, {Event.Read}, 0) var clients = initTable[int, (Socket, Session)]() var seen = if fileExists("reload.stamp"): getLastModificationTime("reload.stamp") else: Time() echo "listening" flushFile stdout while true: for ready in events.select(20): let fd = ready.fd if fd == server.getFd.int: var client: Socket server.accept(client) clients[client.getFd.int] = (client, Session()) events.registerHandle(client.getFd, {Event.Read}, 0) elif fd in clients: let line = clients[fd][0].recvLine() if line.len == 0: events.unregister(fd) clients[fd][0].close() clients.del fd else: clients[fd][0].send(handle(clients[fd][1], line) & "\n") if fileExists("reload.stamp") and getLastModificationTime("reload.stamp") != seen: seen = getLastModificationTime("reload.stamp") let at = getMonoTime() performCodeReload() echo "reloaded in ", (getMonoTime() - at).inMilliseconds, " ms with ", clients.len, " connections" flushFile stdout

  2. Nim の Boehm GC で、落ちるはずのプログラムが 90 分固まっていた --mm:boehm でビルドした Nim のプログラムは、Boehm GC の中で致命的なシグナルが起きると、終わらずに固まっていました。この問題を直した PR(#26515)が、10 月 22 日に Nim の devel にマージされました。今は、メッセージを出してすぐに終わります。この記事では、なぜ固まっていたのか、何を変えたのか、何と引き換えにしたのかを書きます。 CI が 90 分止まった きっかけは CI です。tests/gc/gctest.nim を --gc:boehm で走らせるジョブが、3 回続けて 90 分の上限まで止まったままになりました。原因の一つは二重解放で、それは別の PR(#26506)で直っています。ただ、二重解放ならすぐに落ちるはずです。90 分も固まったのは、別の問題でした。 シグナルハンドラが、自分の持つロックを待っていた 固まった実行に gdb を当てると、シグナルハンドラの中で止まっていました(devel 9f769b15、Debian 12 arm64、libgc 8.2.2)。 #1 pthread_mutex_lock #2 GC_disable (libgc) #3 GC_disable_u0__system #4 signalHandler #5 <signal handler called> #7 raise #8 abort #9 ?? (libgc) #10 GC_free (libgc) 流れは次のとおりです。 Boehm の GC_free が不正な解放(Duplicate large block deallocation)に気づき、アロケータのロックを持ったまま abort を呼ぶ。 Nim の signalHandler が動き、スタックトレースを書く前に GC_disable と newStringOfCap を呼ぶ。 どちらも同じロックを取りにいくが、そのロックは同じスレッドが持ったままなので、永遠に待つ。 signalHandler の上には、もともと「アロケーションの最中にシグナルが来るとここで壊れうる」というコメントがありました。Boehm の場合、その壊れ方が「落ちる」ではなく「固まる」だったわけです。CI にとっては、固まるほうがずっと困ります。ジョブが時間切れになるまで、何も分からないからです。 何を変えたか Boehm のときは、signalHandler がスタックトレースを書かず、メッセージだけを書いて終わるようにしました。 新しい経路は足していません。signalHandler には、スタックトレースを持たないビルドのための「メッセージだけを書く」分岐が既にあります。Boehm のときもそちらを通るように、when の条件を 1 つ変えただけです。Boehm を使わないビルドでは、生成されるコードは前と同じです。 PR のときに Debian 12 arm64(libgc 8.2.2)で確かめた結果は次のとおりです。大きなブロックを 2 回解放するテスト(tests/gc/tboehm_signal.nim)で比べました。 結果 修正前 何も出力せずに固まる。テストに仕込んだ alarm(30) で 32 秒後に打ち切られた 修正後 1 秒で SIGABRT: Abnormal termination. を出して終わる testament cat gc は 79/79 で、gctest の Boehm の 2 つの実行も通りました。 引き換えにしたもの Boehm でビルドしたプログラムは、シグナルを受けたときにスタックトレースを出さなくなりました。Boehm の中のシグナルだけでなく、nil の参照外しのような普通のシグナルでも同じです。ほかのメモリ管理(ORC、ARC、refc)は変わりません。 この修正は devel に入りました。リリースされている版(2.2 系)への backport は、今のところ決まっていません。 テストに仕込んだ alarm と、testament のもう一つの問題 上の表の「alarm(30) で打ち切られた」には理由があります。Nim のテストランナー(testament)は、テストの spec に上限時間(timeout)を書いても、プロセスが終わった後に経過時間を比べるだけです。止まったテストを打ち切りません。そのため、固まるテストを testament に普通に渡すと、それ自体が CI を上限まで止めてしまいます。テストの中で alarm を仕込んだのは、そのためです。 今回 90 分待たされたのは、これも理由の一つです。テストが固まっても、どのテストが固まったのかは、ジョブの上限が来るまで分かりませんでした。同じ問題の issue は、2021 年(#17407)と 2024 年(#24278)に出ていて、どちらも開いたままです。 上限時間を過ぎたテストのプロセスを打ち切り、reTimeout として報告する修正を手元で作りました。macOS arm64 と、Linux の arm64(glibc)と amd64(musl)で、止まるテストが数秒で打ち切られることを確かめてあります。今出している PR の結果を見てから、出す予定です。

  3. Nimony:0 が値でない型のグローバル変数と、ref 型に付く手続き 10 月 22 日に、Nimony(Nim の次の世代のコンパイラ)に私の出した修正が 2 件マージされました。 PR 変わったこと #2738 0 が値に含まれない型のグローバル変数を初期値なしで書くと、コンパイルエラーになる #2739 ref 型に付けた手続きが、別のモジュールからでも見つかる どちらも、言語が約束していることを、その約束を既に守っている仕組みを広げて守らせた修正です。この記事では、何が変わったかを、マージの前(fa0cb46c)と後(00e4e202)の Nimony で実際に動かした結果で示します。 1. 0 が値でない型のグローバル変数が、コンパイルエラーになった よく見かける戸惑い Nim 2 で範囲型の変数を初期値なしで宣言すると、範囲に入らない値が入ります。Nim 2.2.12 で確かめると、こうなります。 var a: 1..3 echo a # 0 a = 0 # Error: cannot convert 0 to range 1..3(int) a = a # 通る。a は 0 のまま 0 を代入しようとするとコンパイルエラーになるのに、宣言しただけの変数には最初から 0 が入っています。しかも a = a は通ります。手続きの中の var a: 1..3 でも同じです。 範囲型の変数を初期値なしで宣言したくなる場面はあります。よく挙がるのは、後ろにあるクロージャの中で値を入れるために、変数だけを先に宣言しておく形です。Nim 2 では、これが黙って通ります。 同じ例を、Nimony のマージの後(00e4e202)でも動かしました。Nimony では範囲型を range[1..3] と書きます。 例 Nim 2.2.12 Nimony(マージの後) グローバルの var a を読む 0 コンパイルエラー(which has no default value) 手続きの中の var a を読む 0 コンパイルエラー(cannot prove that a has been initialized) a = 0 コンパイルエラー コンパイルエラー(value out of range: 0 notin 1..3) 初期値なしで宣言し、クロージャの中で代入してから読む 2 コンパイルエラー(cannot prove that total has been initialized) 初期値を書き、クロージャの中で代入してから読む 2 2 手続きの中の変数については、Nimony にもともと「初期化されたと証明できない変数は読めない」という検査があります。これは範囲型に限りません。var a: int でも、初期値を書かずに読めば同じエラーになります。クロージャの中で代入する形も、int を含めて同じ理由で止まります。初期値を書けば通ります(Nimony では、ローカル変数を取り込む無名の手続きに {.closure.} を付けます)。 0 が紛れ込む穴として残っていたのは、グローバル変数でした。グローバル変数は、初期値が無ければ 0 で埋まる、という扱いのままだったからです。今回の修正で、そこが塞がりました。 何が起きていたか 初期値を書かないグローバル変数は、0 で埋められます。ところが、0 がその型の値でない型があります。range[1..3] や、enum のうち値が 0 の要素を持たないものです。 import std / syncio type R = range[1..3] proc pick(x: R): int = let a = [10, 20, 30] result = a[int(x) - 1] var g: R echo pick(g) Nimony の契約の検査(contracts)は、R 型の値は 1 から 3 のどれかだと信じて、配列の添字の検査を取り除きます。ところが g には 0 が入っているので、a[-1] を読んでしまいます。マージの前の Nimony では、こう出ました。 12536 配列の外のメモリにあった値です。enum でも同じで、enum (red = 1, green = 2) の変数を初期値なしで書くと、どの要素でもない 0 が入っていました。 何が変わったか マージの後の Nimony では、同じプログラムがコンパイルの時点で止まります。 pickrange.nim(6, 5) Error: 'g' is of type '1..3', which has no default value (it contains a range that excludes 0): give it an initial value enum の場合は理由の部分が変わります。 e.nim(4, 5) Error: 'c' is of type 'Color', which has no default value (it contains an enum without a field of ordinal 0): give it an initial value 初期値を書けば、これまでどおり動きます。var g: R = 2 なら 20 が出ます。0 が値に含まれる型(int、range[-5..5]、値が 0 の要素を持つ enum)は、これまでどおり初期値なしで書けて、生成されるコードも変わりません。配列、タプル、オブジェクト、distinct 型の中にこういう型が入っている場合も、同じように検査されます。 直す場所を間違えた話 最初は、意味解析(sem)に新しい検査を足しました。これに Araq さんから「場当たりの修正だ。not nil の型を含むグローバル変数については、契約の検査に同じ検査が既にある。それを広げるべきだ」とコメントが付きました。 言われてみると、そのとおりでした。「初期値なしのグローバル変数は 0 で埋まるが、0 がその型の値でない」という問いには、not nil のポインタについて既に契約の検査が答えていました。そこに range と enum を足せば済みます。sem に別の検査を置くと、同じ問いに答える仕組みが 2 つになり、片方だけが直されて、いずれ食い違います。作り直した PR では sem の変更を取り消し、契約の検査(contracts_fir.nim)だけを広げました。エラーメッセージが 3 つの理由(not nil のポインタ、0 を含まない range、値が 0 の要素の無い enum)のどれかを言うのは、そのためです。 Nim 2 ではどうか Nim 2 では、同じプログラムが警告なしでコンパイルできます(2.2.12 と devel で確かめました)。 ビルド 結果 既定 実行時に index -1 not in 0 .. 2 [IndexDefect] -d:danger 679261(配列の外の値) Nim 2 では、言語の変更として扱う必要があります。そのため、RFC #579 で議論しています。 2. ref 型に付けた手続きが、別のモジュールから見つかるようになった 何が起きていたか Nimony では、手続きを呼ぶとき、引数の型を宣言したモジュールの公開手続きも候補に入ります。そのモジュールを import していなくても、型に「付いている」手続きとして見つかります。オブジェクト型や distinct 型ではこれが働いていましたが、ref 型では働いていませんでした。 m1.nim import std / syncio type Tree* = ref object v: int proc newTree*(v: int): Tree = Tree(v: v) proc write*(f: File; t: Tree) = write f, "tree " & $t.v m2.nim import m1 proc makeTree*(): Tree = newTree(3) main.nim import std / syncio import m2 echo makeTree() main.nim は m1 を import していません。それでも Tree 型は m1 で宣言されているので、echo は m1 の write を見つけられるはずです。ところが、マージの前の Nimony ではこう止まりました。 main.nim(3, 1) Trace: instantiation from here ../../nimony-pre/lib/std/syncio.nim(463, 5) Error: Type mismatch at [position] write stdout, makeTree() [2] expected: string but got: ref Tree.Obj not nil (declared in ../../nimony-pre/lib/std/syncio.nim(288, 1)) 意味解析の後、ref object の型は ref Tree.Obj になります。型が属するモジュールを探す処理(nominalRoot)が ref のところで止まっていたため、引数の型からも引数宣言の型からも、m1 に辿り着けませんでした。 何が変わったか 型が属するモジュールを探すときに、ref や ptr を 1 段だけ透かして見るようにしました。マージの後の Nimony では、同じプログラムが tree 3 を出します。proc f(p: ptr T) のような手続きも T に付くようになりました。 あわせて、言語の文書(doc/language.md)に、この仕組みの説明を足しました。これまでは名前の探し方としてスコープと import しか書かれていませんでした。今は、次のことが書いてあります。 どの手続きが型に付くか: 型を宣言したモジュールにあり、公開されていて、トップレベルにあり、その型の引数を持つもの 候補としての順位: そのモジュールから import した手続きと同じ これで何ができるか: from m import nil で何も import しなくても呼べる。generic の具体化の中でも見つかる おわりに 2 件とも、直し方は同じでした。壊れていた約束を 1 文で書き、それを既に守っている仕組みを探して、そこを広げます。 グローバル変数の修正では、not nil のために既にあった契約の検査を広げました。 ref 型の修正では、オブジェクト型のために既にあった「型が属するモジュールを探す」処理を広げました。 最初の PR で場当たりだと指摘されたのは、この考え方を外れて、新しい検査を並べたときでした。

Achievements

Awards earned by the repositories nim-lang owns. Select an award to embed its badge.

No awards yet

Repositories earn awards by reaching the top three in a completed trending day or week.

A repository earns an award for finishing first, second or third in a completed trending day or week, ranked by new stars, new forks or pull request authors. Awards follow a repository through renames and transfers and count toward its current owner. Weeks run Monday to Sunday, UTC.