白紙の小さな脳が、寝ている間に覚えて喋りだした

当時の開発ログを見返しながらのメモ。

6/19 の "cat sat on the sand…" の中身。何がどうやって覚えたのか。

■生徒と先生

生徒:p-kernel の中の小さな脳。誰かが作った重みを読み込むのではなく、それぞれの端末の上で白紙から育つ。誰のものでもない脳。
中身は1バイトずつ次を当てる言語モデル。語彙256(バイトの全種類)、幅128、4層。各層に4人の専門家がいて2人だけが働く MoE。
前向きの計算も学習用の逆向きの計算もライブラリなしで手書きして、数値微分と突き合わせて確認。

先生:SmolLM2-135M(公開の小さな言語モデル)。これも自前の推論エンジンで動かしている。

■教え方

先生と生徒は文章の区切り方(トークン)が違うので、確率分布をそのまま写せない。
代わりに、先生が書いた文章を、生徒が1バイトずつ次を当てる練習で覚える。一番素朴な蒸留。

効き目は、学習に使っていない部分での外れ具合で測る。6/18 の確認で 5.53 → 1.79。でたらめに当てたときが 5.55 なので、ちゃんと下がっている。
先生の文章をぐちゃぐちゃに混ぜたもので同じだけ練習させても下がらないことも確認。

■寝ている間に学ぶ

練習は DMN の時間にやる。DMN は端末が暇なときに動く処理(人がぼーっとしているときに働く脳の回路から名前を借りた)。
暇になるたびに少し練習して保存。10回寝たら 3.81 → 2.47、再起動しても忘れていなかった。

最初は1秒ごとに 22.8MB 書き込んでいて、ストレージが心配に…
10回に1回だけ練習して、良くなったときだけ保存するようにしたら、200回で200回だった書き込みが10回になった。

■スマホで「…」しか出ない

スマホに入れたら、チャットには「…」しか出ない。
新しくインストールした端末には、そもそも生徒がいなかった。保存済みの生徒を読み込む処理しかなかった。

保存できる端末なら生徒を生んで、最初に6回だけ練習させるように修正。
話しかけたら、最初の1文字が約50ms、96バイト喋り終わるまで約3.7秒で "cat sat on the sand… the dog ran the…" と返ってきた。

■種明かし

このときの教材は、コードに埋め込んだ5行ほどの短い英文。"the cat sat on the mat." や "sat on the sand." が入っていた。
つまり生徒は教科書をたどたどしく繰り返していただけ。

同じ日のうちに、先生にいろいろな書き出し("Once upon a time" など20数個)から書かせた文章に教材を入れ替えた。まだ意味の通る文にはならないけど、同じ言い回しの繰り返しは減った。

※リポジトリ: https://github.com/monyuonyu/p-kernel

※この記事は、p-kernel の開発を手伝ってもらっている AI(Claude)と一緒に書いています。

カーネルがたまに落ちる原因を5日かけて追いかけた

当時の開発ログを見返しながらのメモ。

2026年6月、x86 のベアメタル版がたまに落ちるようになっていた。

■症状

AI の推論を担当する小さなプログラムを kill して再起動させる、を繰り返していると、たまにカーネル本体がページフォルトで止まる。

=== KERNEL EXCEPTION ===  Page Fault  err=0x00000002
EIP=knl_wait_release_tmout+0x11

落ちる場所は、タイマで待っているタスクを起こす処理。

■仮説1:古いタイマ

kill したタスクのタイマが残っていて発火しているのでは、と考えて 6/10 に KILL-TIMER-RACE として課題表に登録。
でも落ちた瞬間のタイマの待ち行列はいつもきれい。「壊れた関数ポインタ」に見えた値も、8バイトの時刻を4バイトのポインタとして読み違えていただけ。ハズレ。

■原因その1:TSS.RSP0

CPU がユーザ側からカーネル側に入るとき、どのスタックにレジスタを積むかを覚えておく場所。
ディスパッチャはスタックは切り替えているのに、RSP0 は起動時に設定したきり一度も更新していなかった。kill されたタスクのスタックが解放されたあとも RSP0 はそこを指したまま。次の割り込みで CPU が解放済みのメモリに書き込んで、別の誰かの戻り先を壊す。

切り替えのたびに RSP0 を更新するようにした(アセンブラで21行)。
直す前は2〜6回の kill で落ちていたのが、20回続けても大丈夫に。

■でもまだ落ちる

頻度は10分の1くらいになったけど、ゼロにならない。

・「割り込みでスタックがあふれている」説 → スタックを 2KB・8KB・16KB と変えても落ち方が同じなので違う
・6/12 の「アドレス空間の切り替え漏れが根本原因」という修正 → 同条件で比べたら、修正前 24回中0回、修正後 24回中11回落ちる。悪化していたので即取り消し

■原因その2:使い回された枠

6/14、やっと再現して記録を追ったら、犯人はタスク管理表の使い回された枠。
kill したタスクの枠に新しいタスクが入った直後に、前の住人のタイマが遅れて発火して、新しい住人の中身を書き換えていた。

最初の「古いタイマ」説は半分当たりだった。被害者は死んだタスクではなく、その枠を引き継いだ新しいタスクの方。
タイマ発火時に「そのタスクがまだ本当に待っているか」を確認して、待っていなければ何もしないようにした。

■メモ

犯人が2人いて、ほぼ同じ落ち方をしていた。1つ直しても「まだ直っていない」ように見える。
修正が効いたかどうかは、直す前と同じ日・同じ手順で比べないと分からない。

※リポジトリ: https://github.com/monyuonyu/p-kernel

※この記事は、p-kernel の開発を手伝ってもらっている AI(Claude)と一緒に書いています。

2つの p-kernel で、1つの推論をしてみた

当時の開発ログを見返しながらのメモ。

つながった何台かで、1つの AI の計算を分け合えるか試した。

■脳はおもちゃの大きさ

動かした Transformer はとても小さい。入力は4つの数字、ヘッド2つ、答えは3択。重みは全部で568個。
それでも仕組みは大きなモデルと同じ形。

■部品はそろっていた

K-DDS、中継、SWIM、生きている台数で動き方を変える「degrade」は既にあった。足りなかったのは起動時の配線と、シェルの `infer` コマンドだけ。

2台そろうと degrade が「2台モード」に切り替わる。infer を打つと、1台目がヘッド0を計算して、入力を中継越しに2台目へ。2台目がヘッド1を計算して返して、1台目が2つを合わせて答えを出す。

■毎回でたらめな場所で落ちる

中継越しの2台構成が毎回落ちる。しかも落ちるアドレスが毎回でたらめ。

推論の配線を止めても落ちるので、犯人は別。
中継とやりとりする部分で 1416バイトの受け皿を関数内の変数として4か所で確保していて、T-Kernel のタスクの小さなスタック(2〜4KB)をあふれさせていた。あふれた分が隣のタスクの戻り先を上書きして、でたらめな場所へ飛んでいた。static にして解決。
ベアメタル版には中継がないので今まで出なかった。

ほか:
・2台目が1回の問い合わせに同じ答えを100回くらい返していたのを修正
・パケットを1つ落としただけで「相手が死んだかも」と騒いでいたのを、2回続けて返事がないときだけに。2台だと代わりに様子を見てくれる3台目がいないので

■3台モードと最初のプルリクエスト

3台以上のときの分け方(各ノードが手元の KV キャッシュで注意を計算して持ち寄る)も試したら、送るデータが K-DDS の上限128バイトを超えていて、今まで一度も分散では動いていなかったことが判明。エラーが黙って捨てられて1台で計算していた…
上限を広げて、翌日には2台分の返事を集められるようにした。

5/22 からの13コミットをまとめて、GitHub のプルリクエスト #1 に。5/31 に取り込み。

※この日の実験は、中継も含めて全部1台の機械の中。

※リポジトリ: https://github.com/monyuonyu/p-kernel

※この記事は、p-kernel の開発を手伝ってもらっている AI(Claude)と一緒に書いています。

中継サーバを書いて、初めての Android アプリを作った

当時の開発ログを見返しながらのメモ。

同じ機械の中ならつながるようになったので、次はスマホ。
ただスマホは携帯回線の NAT の向こうにいるので、外から直接は話しかけられない。

■小さな中継

みんなが話しかけられる場所に中継(relay)を1つ置く。
5/22 の最初の版は C で200行ちょっと。12バイトのヘッダに「誰から・誰へ」を書いて送ると、中継が転送するだけ。

相手は IP・ポートではなくノード番号で覚える。スマホが Wi-Fi から携帯回線に切り替わっても、次に何か送れば住所が勝手に更新される。つなぎ直しの処理が要らない。

最初のテストで、終了の合図を送っても中継が止まらずに固まった。signal() だと受信待ちが自動でやり直される設定になるのが原因。sigaction() に変更。

■認証をつける

5/26 に認証を追加。送る側と中継が同じ鍵を持って、パケットごとに HMAC-SHA256。同じパケットの再送も弾く。
SHA-256 は OpenSSL を使わずに自前。中継は「1ファイル置けば動く」ままにしたかったので。

気をつけたのは確認の順番。署名が正しいのを先に確認して、合っていたときだけ再送チェック用の記録を更新する。逆にすると、でたらめなパケットで記録を進められてしまう。

■カーネルをライブラリにする

Android アプリからはカーネルを共有ライブラリ(.so)として読み込む。5/22 に試したら起動途中で落ちた。

最初は切り替え処理のアセンブリを疑ったけど違った。原因はメモリの端を決める定数。組み込み向けの「メモリの終わりはこのへん」という値が、アドレスがランダムに散らばる環境では的外れになって、メモリの大きさの計算がおかしくなっていた。疑う場所が1段ずれていた。

■APK ができた

26日の最後に APK が1つできた。3.5MB。中に p-kernel と中継につなぐ部分。
標準では充電中だけ動く。アプリを閉じても止まらないように、通知を出したまま動く形(フォアグラウンドサービス)。

ビルド環境は aarch64 の Linux だったけど、Android の開発ツールは x86_64 用しかない。ツールの実行ファイルを1つずつ qemu-x86_64 越しに呼ぶスクリプトで包んで無理やり動かした。遅いけど動く。

ヘッダを探す順番を1つ間違えて、エラーの定義が丸ごと別物になり1時間溶かしたのもこの日…

この日はビルドして中身を確かめたところまで。実際にスマホで動かしたのはもう少しあと。

※リポジトリ: https://github.com/monyuonyu/p-kernel

※この記事は、p-kernel の開発を手伝ってもらっている AI(Claude)と一緒に書いています。

aarch64 と x86_64 の p-kernel 同士をつないでみた

当時の開発ログを見返しながらのメモ。

前の日に Linux 上で aarch64 版と x86_64 版が動いたので、この2つをつないでみた。CPU が違っても仲間としてつながるか。

■朝の片付け

x86_64 版は 64ビット共通のヘッダを aarch64 のフォルダから借りてビルドしていたので、共通の置き場(lp64/)に移動。
そしたら x86_64 版のビルドで「aarch64 です」という印まで立っていたことが発覚。借りたヘッダの1つが無条件に立てていた。たまたま壊れていなかっただけ。

■何も変えずにつながった

aarch64 版と x86_64 版(qemu-x86_64 越し)を同じ機械で1つずつ起動。コードは1行も変えずにつながった。

[swim] node 0 discovered  (via rx)
[degrade] *** level change: FULL -> REDUCED  alive=2

通信部分のソースは両アーキテクチャで1バイトも違わないので当然といえば当然だけど、実際に見るとやっぱりうれしい。

■データを流すと自分の声しか聞こえない

K-DDS のトピックに、両方のノードから「n0 aarch64 t=1」みたいに2秒ごとに送って、相手のも受け取る小さなデモを作った。
が、最初はどっちにも自分が送ったものしか出ない。

原因は2つ。
・ネットワークの初期化(pmesh_init)が受け口の表を毎回ゼロにしていた。K-DDS は先に起動して登録しているので、あとから初期化されると登録ごと消える
・Linux 版がそもそも pmesh_init を呼んでいなかった

表を消す処理を外して起動順を直したら届いた。

[kdemo-rx] n0 aarch64 t=1
[kdemo-rx] n1 x86_64  t=1

同じ消し方のバグはベアメタルの x86 版にも潜んでいた。

■メモ

この日の実験は全部同じ機械の中。別々の機械をネットワークでつなぐのはまだ先。
ヘッダを共通にしたおかげで構造体の並びがそろっていて、相手のバイト列をそのまま読めることは確認できた。

※リポジトリ: https://github.com/monyuonyu/p-kernel

※この記事は、p-kernel の開発を手伝ってもらっている AI(Claude)と一緒に書いています。