2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LLVM(プロジェクトファイルサイズ5.8GB)を57秒で解体する。C++20とTree-sitterで自作した超高速・省メモリなLocal MCPサーバー

2
Posted at
Page 1 of 6

LLVM(5.8GB)を57秒で解体する、超高速・省メモリな「AIエージェント向けLocal MCPサーバー」をC++20とTree-sitterで自作した話

はじめに

2026年、AIエージェントやMCP(Model Context Protocol)の普及により、LLMにコードベースを理解させる機会が爆発的に増えています。しかし、LLMは統計計算機であり、数ギガバイトに及ぶ巨大なリポジトリのテキストをそのまま丸呑みさせれば、コンテキストは瞬時に崩壊し、ハルシネーション(幻覚)を引き起こします。

AIが本当に必要としているのは、テキストストリームではなく、コードの**「正しい幾何学(構造)」**です。

そこで、LLVM級の巨大コードベースを**ローカルで1分未満・910MB以下の物理メモリ(RES)**で完全把握し、AIに「構造の地図」を提供する超筋肉質なLocal MCPサーバー mcp-cst-core をC++20とTree-sitterで構築しました。SDKすら使わない、ハッカーのためのストイックな実装の全貌を解説します。

🔥 圧倒的なパフォーマンス(実測値)

巨大なオープンソースプロジェクトである llvm-project を対象に、一般的なミニPC環境でベンチマークを行いました。

  • 対象コードベースサイズ: 5.8 GB
  • 全ファイルのパース&インデックス速度: 57,444 ms (1分未満!)
  • ピーク時の物理メモリ使用量(RES): 901 MB

12GB程度の空きメモリしかない開発環境でも、コンテキストを溢れさせることなく、裏で仮想メモリとキャッシュをオーケストレーションしながら高速に解体します。

🎬 10スレッドが爆走してLLVMを解体するデモ動画

LLVMをCST解析しメモリ展開(プロジェクトファイルサイズは5.6G) - YouTube


📊 処理フローとシーケンス

本システムがどのように標準入力(STDIO)を介してAIと対話し、マルチスレッドで高速パースを行っているかの全体像です。(※Qiitaの標準機能でシーケンス図にレンダリングされます)


⚙️ 贅肉を削ぎ落とした3つの設計思想

1. 土管(MCP)は分ける必要なし、main直撃の設計

通信の窓口に過ぎないMCP層(STDIO / JSON-RPC)の過剰なクラス隠蔽や抽象化を徹底的に拒絶しました。main() が直接標準入力のI/Oループをストレートに掴むことで、無駄なオブジェクト生成や抽象化のオーバーヘッドをゼロにしています。

2. 「メイン(土管)とCST(構造)」の一刀両断

唯一分ける conceptual な境界は、通信の土管(main)と、重厚な解析・メモリ管理を司る CSTAnalyzer クラスのみ。この引き算の美学により、主要ロジックを美しいシングルソース(約400行)で実現しています。

3. AIファーストな三段階API

AIにソースコードを丸呑みさせるのではなく、ローカルで構築した地図を段階的にAIの頭脳に渡していく、完璧な役割分担を持ったインターフェース設計を採用しています。

  • CreateCST(メモリ展開と解析):
    AIから指示されたプロジェクトのパスを起点に、10スレッドを爆走させて全ファイルをパース。重い構文解析木は即座に破棄し、最小限の物理座標(インデックス)と生ソースだけをメモリに展開・蓄積します。
  • getProductSummary(全体把握):
    解析によって出来上がった地図から、シンボル名とファイル名、行数だけの軽量な一覧をAIに差し出し、まずは全体の幾何学マップ(概要)を把握させてハルシネーションの檻から解放します。
  • getMethodDetail(詳細スライス):
    概要を見たAIが「この関数の実装が見たい!」と指し示した瞬間に、保持している物理座標を元に、該当コードの「赤身肉」だけをミリ秒単位でピンポイントにスライスして差し出します。

🛠 実装の核心:並列パースとメモリ展開

コードの全貌はGitHubリポジトリに公開していますが、5.8GBを1GB未満でねじ伏せる核心部分である、CSTAnalyzer の並列ワークユニットロジックと、main関数の通信の入り口部分を抜粋します。

// 【核心】贅肉を削ぎ落としたインデックス構造とキャッシュ
class CSTAnalyzer {
    // 詳細(赤身肉)を求められた時だけ、ここからコードを切り出すための生ソースキャッシュ
    std::unordered_map<std::string, std::string> file_cache;

    struct NodeInfo { 
        uint32_t s_byte; uint32_t e_byte; std::string file_path; uint32_t row; 
    };

    // シンボル名から物理座標を一瞬で引くための巨大な地図
    std::unordered_map<std::string, NodeInfo> symbol_index;
    std::mutex mtx; 
    int max_threads; 

public:
    explicit CSTAnalyzer(int j) : max_threads(j) {}

    // マルチスレッドで5.8GBを爆速走査するコアロジック
    bool scan_parallel(const std::string& root, const std::unordered_map<std::string, LangTrait>& lmap) {
        clear();
        std::vector<std::jthread> workers;
        std::counting_semaphore<256> sem(max_threads); // メモリの檻を守る門番(10スレッド制限)

        for (const auto& entry : fs::recursive_directory_iterator(root)) {
            if (!entry.is_regular_file() || !lmap.count(entry.path().extension().string())) continue;

            std::string path = entry.path().string();
            const auto& trait = lmap.at(entry.path().extension().string());

            sem.acquire(); // 門番に許可を求める

            workers.emplace_back([this, &sem, path, trait]() {
                std::ifstream ifs(path, std::ios::binary);
                if (!ifs) { sem.release(); return; }

                // ① ソース全体をメモリ(src)に展開
                std::string src((std::istreambuf_iterator<char>(ifs)), std::istreambuf_iterator<char>());
                
                // ② Tree-sitter のパース実行(ここで「木」が生まれる)
                TSParser* lp = ts_parser_new(); 
                ts_parser_set_language(lp, trait.get_lang()); 
                TSTree* lt = ts_parser_parse_string(lp, nullptr, src.c_str(), src.size());
                
                std::vector<std::pair<std::string, NodeInfo>> local_indices;
                if (lt) {
                    build_local_index(path, lt, trait, src, local_indices); // 座標抽出
                    ts_tree_delete(lt); // 【最重要】用が済んだら、巨大な「木」は即座に破棄!
                }
                ts_parser_delete(lp); 

                // ③ 全体のCSTに、解析した地図情報と生ソースを統合
                {
                    std::lock_guard<std::mutex> lock(mtx);
                    file_cache[path] = std::move(src); // std::moveで生ソースをキャッシュへ高速転送
                    for (auto& item : local_indices) symbol_index[std::move(item.first)] = item.second;
                }
                sem.release(); 
            });
        }
        return true;
    }
};

// ―― ここでAI(MCP)からのリクエストを受け取り、メモリ展開(CreateCST)へ繋ぐ ――
int main(int argc, char* argv[]) {
    // 中略(I/O高速化や言語マップ初期化など)
    
    std::string line;
    while (std::getline(std::cin, line)) {
        try {
            auto req = json::parse(line);
            json res = {{"jsonrpc", "2.0"}};
            if (req.contains("id")) res["id"] = req["id"];

            // MCPプロトコルのツール呼び出し(tools/call)をフック
            if (req["method"] == "tools/call") {
                std::string tn = req["params"]["name"]; 
                auto args = req["params"]["arguments"];

                // 【ここが入り口】AIから指示されたパスを引数に、メモリ展開&パースを爆走させる
                if (tn == "CreateCST") {
                    if(analyzer.scan_parallel(args["path"], lmap)) {
                        res["result"] = {{"content", {{{"text", "OK", "type", "text"}}}}};
                    } else {
                        res["result"] = {{"content", {{{"text", "NG", "type", "text"}}}}};
                    }
                }
                // getProductSummary や getMethodDetail などの処理へ続く...
            }
            std::cout << res.dump() << std::endl; // AIへJSON-RPCで回答
        } catch (...) {}
    }
    return 0;
}

💡 メモリ(RES)を限界まで抑え込めたポイント

Tree-sitterで普通にパースし、生成された抽象構文木(TSTree)をそのままメモリに保持し続けると、LLVM級の巨大コードベースではあっという間に数十ギガバイトのメモリを消費してクラッシュします。

本実装では、上記のコードにある通り、最小限の座標数値(NodeInfo)だけを抜き出した後、1ファイルごとに ts_tree_delete(lt) で木を即座に完全破棄しています。

この徹底的な「使い捨て」と、コピーを発生させない std::move による拡張メモリへソースを転送こそが、圧倒的な省メモリ性能を支えています。

🧠 最も苦労した点:実行してはKILLされる絶望から、この構造にたどり着いた

この開発において、最も頭を悩ませ、泥臭く闘ったのが**「物理メモリ(RES)の徹底的な抑制」**です。

正直に告白すると、開発初期は**「実行してはOS(OOM Killer)に一瞬でプロセスをKILLされる」という絶望を、文字通り数え切れないほど繰り返しました。**

Tree-sitterは非常に高速で優れた構文解析器ですが、愚直に全ファイルの抽象構文木(TSTree)をメモリに保持し続けると、LLVM級の巨大リポジトリでは数十ギガバイトのメモリを一瞬で喰い尽くします。私の開発環境(利用可能メモリ約12GBのミニPC)では、どれだけアルゴリズムを工夫しても容赦なくKILLされ、完全に手詰まりに見えました。

「全ファイルの構造を保持しなければならない。でも保持したらKILLされる」
この矛盾をねじ伏せるために、何十回ものクラッシュの果てにたどり着いたブレイクスルーが、今回のストイックなメモリ管理アーキテクチャです。

1. 巨大な「木」は数ミリ秒の命。即座の完全解体

OSにKILLされないための最大の答えがこれでした。各スレッド(jthread)がファイルをパースして TSTree が生まれた瞬間、そこからS式クエリの型紙を使って「開始・終了バイト・行数」というわずか数バイトの数値(NodeInfo)だけを抜き出し、直後の数ミリ秒以内に ts_tree_delete(lt) で木を跡形もなく完全破棄しています。
「重厚なツリー構造は用が済んだら1ミリ秒もメモリに居座らせない」という設計にシフトしたことで、ついにKILLのループを抜け、物理メモリを901MBに抑え込むことに成功しました。

2. メモリコピーの徹底排除(std::move のオーケストレーション)

10スレッドが同時にパースしたローカルな地図を全体の巨大な地図(symbol_index)に統合する際、一切の文字列コピー(ディープコピー)を発生させないよう、徹底的に std::move でメモリの所有権だけを高速転送しました。バッファの無駄な重複を1バイトたりとも許さない設計です。

3. セマフォによる「暴走」の調教

C++20の std::counting_semaphore を使い、同時に動く並列ワークユニットをハードウェアのスイートスポットである「10」にガチガチに制限しました。これを行わないと、スレッドが無限に立ち上がり、一時的なメモリ展開(src)だけで物理メモリの限界を超えて再びKILLされてしまうため、厳格な門番として機能させています。

技術的な達成の裏にある、この「KILLされ続けた絶望と、1バイトの贅肉も許さない執念」の闘いこそが、mcp-cst-core の真の心臓部です。

おわりに

今はまだ、この超高速ローカルインフラの真のポテンシャルを完全に解放できるAIエージェントは存在しないかもしれません。しかし、これは明確に**“次世代のAIエージェント”のために先行してハックした構造基盤**です。

ローカル解析ファースト、そしてプライバシー安全な構造解析がこれからの標準になる未来へ。

ビルド方法やJSON-RPCによる通信のテストシーケンスを含む、すべてのソースコードはGitHubで公開しています。もしよければStarをお願いします!

👉 GitHub: kenjiigarashi/mcp-cst-core

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?