<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet href="/feeds/atom-style.xsl" type="text/xsl"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <id>https://kinnari-blog.vercel.app/</id>
    <title>Kinnari's Blog</title>
    <updated>2025-11-28T13:28:39.483Z</updated>
    <generator>Astro-Theme-Retypeset with Feed for Node.js</generator>
    <author>
        <name>Kinnari</name>
        <uri>https://kinnari-blog.vercel.app/</uri>
    </author>
    <link rel="alternate" href="https://kinnari-blog.vercel.app/"/>
    <link rel="self" href="https://kinnari-blog.vercel.app/atom.xml"/>
    <subtitle>Kinnari 的博客</subtitle>
    <rights>Copyright © 2025 Kinnari</rights>
    <entry>
        <title type="html"><![CDATA[Obsidian 坚果云同步方法]]></title>
        <id>https://kinnari-blog.vercel.app/posts/obsidian-sync/</id>
        <link href="https://kinnari-blog.vercel.app/posts/obsidian-sync/"/>
        <updated>2025-11-06T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[[!NOTE] 使用坚果云同步 Obsidian 的方法，在 PC 端和移动端之间使用。首先你需要在 PC 端有一个 Obsidian 仓...]]></summary>
        <content type="html"><![CDATA[<blockquote>
<p>[!NOTE]</p>
<p>使用坚果云同步 Obsidian 的方法，在 PC 端和移动端之间使用。</p>
</blockquote>
<p>首先你需要在 PC 端有一个 Obsidian 仓库，假设在路径 <code>~/Obsidian</code> 下。</p>
<p>前往官方插件仓库下载最新的 release：<a href="https://github.com/nutstore/obsidian-nutstore-sync">https://github.com/nutstore/obsidian-nutstore-sync</a>。下载好之后解压到插件目录 <code>~/Obsidian/.obsidian/plugins</code>，然后重启 Obsidian 即可开始使用。</p>
<p>在配置界面根据提示设置好 WebDav，如下图所示：</p>
<p><img src="./_image/nutstore-plugin.png" alt="设置好的参考界面" /></p>
<p>其他的一些配置选项就看个人喜好进行设置即可。接着在命令面板选择开始同步，等待同步完成。</p>
<p>最后在移动端（我用的安卓）安装 Obsidian，但暂时不要新建仓库。将电脑上的仓库 <code>~/Obsidian</code> 复制到手机上某个位置，再打开手机上的 Obsidian 选择“打开已有仓库”，这时就会自动开始加载仓库、插件等，再次同步即可完成。</p>
]]></content>
        <author>
            <name>Kinnari</name>
            <uri>https://kinnari-blog.vercel.app/</uri>
        </author>
        <published>2025-11-06T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[plox 实现简介]]></title>
        <id>https://kinnari-blog.vercel.app/posts/plox/</id>
        <link href="https://kinnari-blog.vercel.app/posts/plox/"/>
        <updated>2025-10-25T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[使用 GPT-5 生成，本人做了小幅度改动。GitHub: https://github.com/KinnariyaMamaTanha/p...]]></summary>
        <content type="html"><![CDATA[<blockquote>
<p>使用 GPT-5 生成，本人做了小幅度改动。</p>
<p>GitHub: <a href="https://github.com/KinnariyaMamaTanha/plox">https://github.com/KinnariyaMamaTanha/plox</a></p>
</blockquote>
<p>本项目是用 Python 实现的 Lox 语言解释器（来源于《<a href="https://craftinginterpreters.com/">Crafting Interpreters</a>》），但在工程组织、静态解析与运行时设计上做了更贴合 Python 的细化。</p>
<h2>整体思路与执行流程</h2>
<p>解释器采用典型的多阶段流水线：扫描（词法）→ 解析（语法）→ 语义解析（作用域/解析距离）→ 执行（解释）。命令行既支持执行脚本，也支持带历史与多行输入的 REPL。</p>
<p><img src="./_img/pipeline.png" alt="" /></p>
<p>顶层入口位于 <code>plox.py</code> 的 <code>run()</code>：</p>
<pre><code>scanner = Scanner(source)
tokens = scanner.scan_tokens()
parser = Parser(tokens)
statements = parser.parse()
if error.has_error: return

_interpreter = interpreter or Interpreter()
_interpreter.locals.clear()
resolver = Resolver(_interpreter)
resolver.resolve(statements)
if error.has_error: return

_interpreter.interpret(statements)
</code></pre>
<p>这段展示了编译期错误（扫描/解析/解析器）与运行期错误（解释执行）的分层处理，也为 REPL 复用同一个 <code>Interpreter</code> 保留了空间（从而实现同一会话内变量与函数的持久化）。</p>
<h2>AST 与访问者模式</h2>
<p>语法树使用 <code>dataclass</code> 建模在 <code>src/lox/expr.py</code> 与 <code>src/lox/stmt.py</code> 中，并通过 <code>accept(self, visitor)</code> 与 <code>visitor</code> 双分派实现扩展。比如表达式：</p>
<pre><code>@dataclass(eq=False)
class Binary(Expr):
    left: Expr
    op: Token
    right: Expr
    def accept(self, visitor: ExprVisitor):
        return visitor.visit_binary(self)
</code></pre>
<p>这让不同“阶段”（如 AST 打印器、语义解析器、解释器）都能以独立的 Visitor 实现，彼此解耦。例如 <code>ast_printer.py</code> 专注结构可视化，<code>resolver.py</code> 专注作用域与闭包分析，<code>interpreter.py</code> 专注运行时语义。</p>
<blockquote>
<p>[!DETAILS] <strong>Visitor Pattern</strong></p>
<p>这是我在写 plox 的时候收获最大的一个地方。在传统的 OOP 模式下，每次新增加一种方法，都需要找到对应多个（子）类的相关源码进行修改。也就是说，每次增加功能都会导致类的源码被修改。这显然违背了软件设计中的“开闭原则”（对扩展开放，对修改关闭）。而访问者模式巧妙地解决了这个问题。它引入了一个“访问者”的角色，这个访问者专门负责对对象结构中的每个元素执行特定的操作。由此，实现一个新的功能只需要新写一个 visitor 类即可：被访问类本身保持稳定，无需任何修改，而新的操作可以作为新的访问者类被轻松地添加进来。</p>
<p>在 plox 中，则体现为对 <code>Expr</code> 和 <code>Stmt</code> 这两个类的数十个子类，为了实现 parse, resolve 以及 interpret 的功能，我们并不需要每次都修改这些子类，而是新定义 <code>Parser</code>, <code>Resolver</code>, <code>Interpreter</code> 这三个类，对后两者而言继承父类 <code>ExprVisitor</code> 和 <code>StmtVistior</code>，实现若干 <code>visit_xxx</code> 功能即可。</p>
<p>在此视角下，<code>Expr</code> 和 <code>Stmt</code> 这两个类的数十个子类可以被看作是不同类型的数据，或者说，节点（node）。它们抛出可以 <code>accept</code> <code>Visitor</code> 抽象类的方法，将自己的一切 fields（甚至还可以有 methods）交给一个 <code>Visitor</code> 处理。node 决定了有哪些形式的数据可以利用，而 visitor 则决定了如何利用这些数据。</p>
<p><img src="./_img/visitor_pattern.png" alt="" /></p>
</blockquote>
<h2>解析器与语法糖</h2>
<p><code>Parser</code> 采用递归下降法实现 Lox 的表达式与语句子集，同时做了两点实用处理：</p>
<ul>
<li><code>for</code> 循环被语法糖化为 <code>var/init + while + increment</code> 的组合，生成更基础的 AST，解释器无需关心语法糖细节。</li>
<li><code>break/continue</code> 通过 <code>loop_depth</code> 做静态检查，防止在循环外使用；在运行时通过轻量异常传递到最近一层循环；<code>return</code> 同理用于从函数体中携带返回值的“非本地跳转”。这种做法让解释器主循环保持整洁，同时在 Python 上有非常直接的实现路径。命令行执行文件时，若发生编译期错误会以退出码 65 结束，运行期错误则以退出码 70 结束，便于外部脚本感知失败类型。</li>
</ul>
<p>另外，<code>Scanner</code> 使用 Python 的 <code>match/case</code> 清晰映射字符到 Token 类型，并内置关键字表（含 <code>break</code>/<code>continue</code>、<code>this</code>/<code>super</code> 等）。</p>
<h2>作用域解析与“距离”</h2>
<p><code>Resolver</code> 是一大亮点：它在执行前对每个变量引用计算其与声明点的“作用域距离”，并记录到解释器的 <code>locals: Dict[Expr, int]</code> 中。解释器取值时可以 O(1) 定位到正确的嵌套环境，避免运行时从外向内逐层搜寻。</p>
<p>解析的核心逻辑大致是：</p>
<pre><code># resolver.py
for i, scope in enumerate(reversed(self.scopes)):
    if name.lexeme in scope:
        self.interpreter.resolve(expr, i)  # 记录距离
        return
</code></pre>
<p>配合解释器侧的查询：</p>
<pre><code># interpreter.py
def lookup_variable(self, expr, name):
    distance = self.locals.get(expr)
    if distance is not None:
        return self.environment.get_at(distance, name.lexeme)
    return self.globals.get(name)
</code></pre>
<p>这套机制还支持 <code>this</code> 与 <code>super</code> 的静态合法性检查与绑定，使类与继承的语义更健壮（例如禁止类从自身继承、禁止非子类中使用 <code>super</code>）。</p>
<h2>运行时与闭包/类</h2>
<p>运行时以 <code>Environment</code> 串起词法作用域链，<code>Interpreter</code> 以 Visitor 实现求值逻辑。函数、类与实例通过统一的 <code>LoxCallable</code> 协议抽象，统一了“可调用”与“参数个数（arity）”语义：</p>
<pre><code># 调用表达式
callee = self.evaluate(expr.callee)
arguments = [self.evaluate(a) for a in expr.arguments]
if not isinstance(callee, LoxCallable):
    raise PloxRuntimeError(expr.paren, "Can only call functions and classes.")
if len(arguments) != callee.arity():
    raise PloxRuntimeError(expr.paren, f"Expected {callee.arity()} but got {len(arguments)}.")
return callee(self, arguments)
</code></pre>
<ul>
<li>函数 <code>LoxFunction</code> 捕获定义时环境形成闭包，<code>return</code> 通过抛出 <code>ReturnException</code> 回传值；构造器 <code>init</code> 无论显式 <code>return</code> 与否都返回绑定的 <code>this</code>。</li>
<li>类 <code>LoxClass</code> 自身也是 <code>LoxCallable</code>，调用它会创建实例并自动查找并执行 <code>init</code>；方法查找支持在原类和父类中逐层向上。</li>
<li>原生函数以 <code>Clock</code> 为例被注入到全局环境，扩展新原生函数非常容易。</li>
</ul>
<h2>REPL 与工程化小细节</h2>
<p>REPL 使用 <code>prompt_toolkit</code> 提供历史记录与良好交互；<code>utils.is_complete_source()</code> 基于括号/花括号平衡与末尾字符的简单启发式，帮助判断是否需要继续读取多行，体验友好。更重要的是 REPL 在同一 <code>Interpreter</code> 实例上运行，这意味着你在上一行定义的变量、函数和类，在下一行立即可用，贴近日常脚本探索。</p>
<p>错误处理方面，编译期与运行期各有独立的全局标志位与上报函数，命令行在执行文件时会基于这些标志以不同退出码结束，便于脚本化集成与测试。</p>
<h2>一个小例子</h2>
<p>下面这段 Lox 代码展示了类与继承、闭包、以及循环控制。你可以把它粘到 REPL（支持多行输入与历史），或保存为脚本运行。</p>
<pre><code>fun makeCounter() {
    var i = 0;
    fun inc() {
        i = i + 1;
        return i;
    }
    return inc; // 返回闭包，捕获上层 i
}

class Animal {
    init(name) { this.name = name; }
    say() { print this.name; }
}

class Dog &lt; Animal {
    say() {
        super.say();
        print "woof";
    }
}

var d = Dog("Pluto");
d.say(); // Pluto\nwoof

var next = makeCounter();
print next(); // 1
print next(); // 2

for (var j = 0; j &lt; 3; j = j + 1) {
    if (j == 1) continue; // 跳过 1
    print j;
}
</code></pre>
<p>这段代码运行时，<code>Resolver</code> 会为诸如 <code>this</code>、<code>super</code>、以及闭包中的 <code>i</code> 计算解析距离；解释器据此在 <code>Environment</code> 链上精确取值或赋值。构造 <code>Dog("Pluto")</code> 时，类作为可调用对象会创建实例并自动查找并调用 <code>init</code>，方法查找支持在父类中向上解析；循环中的 <code>continue</code> 则通过异常快速跳回条件判断，从而跳过当次循环体剩余语句。</p>
<h2>总结</h2>
<p>整体架构沿着“阶段清晰、关注点分离”的路线推进：扫描/解析/解析器/执行彼此独立，AST + Visitor Pattern 可以在任一阶段安全扩展能力；Resolver 的“距离”机制在保证静态合法性的同时又让运行期查找高效；控制流用异常传递实现 <code>break/continue/return</code>，实现简单且语义一目了然；类/函数/实例遵循统一的调用契约，新增原生能力只需实现 <code>LoxCallable</code> 并注册到全局环境。</p>
<p>当然这只是对 lox 语言的一个简单实现，如果想要继续扩展，可以考虑：添加更多原生函数（I/O、数据结构）、为数字/字符串引入更多内置方法、或在解析阶段支持新的语法糖；这些改动都能在现有模块清晰落位，不会相互牵连。</p>
]]></content>
        <author>
            <name>Kinnari</name>
            <uri>https://kinnari-blog.vercel.app/</uri>
        </author>
        <published>2025-10-25T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[nano-vllm 源码阅读]]></title>
        <id>https://kinnari-blog.vercel.app/posts/nano-vllm/note/</id>
        <link href="https://kinnari-blog.vercel.app/posts/nano-vllm/note/"/>
        <updated>2025-10-17T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[最近突发奇想想要读一下 vllm 的源码，原因是对这个挺感兴趣的，另外之前接触过一段时间，但是一直没有深入下来了解过它的源码是怎么写的（顺便...]]></summary>
        <content type="html"><![CDATA[<h2>前言</h2>
<p>最近突发奇想想要读一下 vllm 的源码，原因是对这个挺感兴趣的，另外之前接触过一段时间，但是一直没有深入下来了解过它的源码是怎么写的（顺便消磨点时间学点有用的），但是担心源码太多读不下来，所以就选择了更精简的版本 <a href="https://github.com/GeeeekExplorer/nano-vllm">nano-vllm</a>。下面是我边读边做的一些笔记，个人感觉还是非常详细的。另外内容难免有错误，还望能够在评论区指出，咱们一同学习进步。</p>
<h2>项目结构</h2>
<pre><code>nano-vllm
├── bench.py
├── example.py
├── LICENSE
├── nanovllm
│   ├── config.py
│   ├── engine
│   │   ├── block_manager.py
│   │   ├── llm_engine.py
│   │   ├── model_runner.py
│   │   ├── scheduler.py
│   │   └── sequence.py
│   ├── __init__.py
│   ├── layers
│   │   ├── activation.py
│   │   ├── attention.py
│   │   ├── embed_head.py
│   │   ├── layernorm.py
│   │   ├── linear.py
│   │   ├── rotary_embedding.py
│   │   └── sampler.py
│   ├── llm.py
│   ├── models
│   │   └── qwen3.py
│   ├── sampling_params.py
│   └── utils
│       ├── context.py
│       └── loader.py
├── pyproject.toml
└── README.md
</code></pre>
<p>其中</p>
<ul>
<li><code>engine</code> 文件夹：nano-vllm 的核心，实现了 kv cache、paged attention 等功能</li>
<li><code>layers</code> 文件夹：定义了模型的所有组件，例如 Attention、embed_head、layernorm 等</li>
<li><code>models</code> 文件夹：利用 <code>layers</code> 中的组件定义了 qwen3 模型类</li>
<li><code>utils</code> 文件夹：一些小工具</li>
</ul>
<h2>example.py</h2>
<blockquote>
<p>nano vllm 有一个非常棒的入口文件 example.py，我决定从这里开始自顶向下阅读</p>
</blockquote>
<p>这个文件一共就只有 34 行，完成了 5 个动作</p>
<ol>
<li>确定模型路径：<code>path = os.path.expanduser("~/huggingface/Qwen3-0.6B/")</code></li>
<li>加载 model 和 tokenizer：</li>
</ol>
<pre><code>tokenizer = AutoTokenizer.from_pretrained(path)
llm = LLM(path, enforce_eager=True, tensor_parallel_size=1) # LLM 就是 LLMEngine 的一层名字上的包装
</code></pre>
<ol>
<li>确定采样参数：<code>sampling_params = SamplingParams(temperature=0.6, max_tokens=256)</code></li>
<li>准备 prompts：</li>
</ol>
<pre><code>prompts = [
    "introduce yourself",
    "list all prime numbers within 100",
]
prompts = [
    tokenizer.apply_chat_template(
        [{"role": "user", "content": prompt}],
        tokenize=False,
        add_generation_prompt=True,
        enable_thinking=True
    )
    for prompt in prompts
]
</code></pre>
<ol>
<li>推理：<code>outputs = llm.generate(prompts, sampling_params)</code></li>
</ol>
<h2>采样参数 SamplingParams</h2>
<p>采样参数包括三个值</p>
<pre><code># nanovllm/sampling_params.py
from dataclasses import dataclass

@dataclass
class SamplingParams:
    temperature: float = 1.0
    max_tokens: int = 64
    ignore_eos: bool = False
</code></pre>
<ul>
<li><code>temperature</code>: 在 attention 操作的 softmax 中使用的温度，控制生成下一个 token 的混乱程度，值越低（越接近 0）则结果越确定，值越高（1 或更高）则回答更多样。控制公式：</li>
</ul>
<p>$$
\text{SoftMax}(x_i) = \frac{e^{x_i/T}}{\sum_{j=1}^N e^{x_j/T}}
$$</p>
<ul>
<li><code>max_tokens</code>: 控制最长<strong>回答</strong>长度，参见下面的代码：</li>
</ul>
<pre><code># nanovllm/engine/scheduler.py, line 68:
if (not seq.ignore_eos and token_id == self.eos) or seq.num_completion_tokens == seq.max_tokens:
    seq.status = SequenceStatus.FINISHED
    self.block_manager.deallocate(seq)
    self.running.remove(seq)

# nanovllm/engine/sequence.py, line 42:
@property
def num_completion_tokens(self):
    return self.num_tokens - self.num_prompt_tokens
</code></pre>
<ul>
<li><code>ignore_eos</code>: 是否忽略 <code>eos</code>（end of sequence），如果不忽略（值为 <code>False</code>）的话，当一个 token 的 id 是代表 <code>eos</code> 时，会停止当前请求的生成。涉及到的代码如下：</li>
</ul>
<pre><code># nanovllm/engine/scheduler.py, line 68
if (not seq.ignore_eos and token_id == self.eos) or seq.num_completion_tokens == seq.max_tokens:
    seq.status = SequenceStatus.FINISHED
    self.block_manager.deallocate(seq)
    self.running.remove(seq)
</code></pre>
<h2>prompts</h2>
<p>对于不同的模型，对话格式可能会有差别（不仅仅局限于 <code>&lt;|user|&gt;</code> 或 <code>&lt;|assistant|&gt;</code> 或 <code>&lt;|end_of_message|&gt;</code> 这类标签），可以使用 huggingface 提供的 <code>apply_chat_template</code> 方法便捷地进行 prompt 的准备，而无须手动调整格式，例如 huggingface 官方给出的例子：</p>
<pre><code>from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("HuggingFaceH4/zephyr-7b-beta")
chat = [
  {"role": "user", "content": "Hello, how are you?"},
  {"role": "assistant", "content": "I'm doing great. How can I help you today?"},
  {"role": "user", "content": "I'd like to show off how chat templating works!"},
]

print(tokenizer.apply_chat_template(chat, tokenize=False))
</code></pre>
<p>输出为</p>
<pre><code>&lt;|user|&gt;
Hello, how are you?&lt;/s&gt;
&lt;|assistant|&gt;
I'm doing great. How can I help you today?&lt;/s&gt;
&lt;|user|&gt;
I'd like to show off how chat templating works!&lt;/s&gt;
</code></pre>
<p>需要注意的是，传入的 input 需要是 <code>{"role": "xxx" "content": "yyy"}</code> 这种格式的字典组成的列表，其中 "xxx" 的取值为："user", "assistant", "system"，分别表示用户输入、模型回答、系统提示词。</p>
<p>当参数 <code>tokenize</code> 为 True 时，将返回 tokenize 之后的结果，还是用上面的例子，得到输出：</p>
<pre><code>[523, 28766, 1838, 28766, 28767, 13, 16230, 28725, 910, 460, 368, 28804, 2, 28705, 13, 28789, 28766, 489, 11143, 28766, 28767, 13, 28737, 28742, 28719, 2548, 1598, 28723, 1602, 541, 315, 1316, 368, 3154, 28804, 2, 28705, 13, 28789, 28766, 1838, 28766, 28767, 13, 28737, 28742, 28715, 737, 298, 1347, 805, 910, 10706, 5752, 1077, 3791, 28808, 2, 28705, 13]
</code></pre>
<p>而参数 <code>add_generation_prompt</code> 控制是否在输入最后加上提示 assistant 开始的 token，还是用上面的例子：</p>
<pre><code>print(tokenizer.apply_chat_template(chat, tokenize=False, add_generation_prompt=False))
# 输出：
# &lt;|user|&gt;
# Hello, how are you?&lt;/s&gt;
# &lt;|assistant|&gt;
# I'm doing great. How can I help you today?&lt;/s&gt;
# &lt;|user|&gt;
# I'd like to show off how chat templating works!&lt;/s&gt;

print(tokenizer.apply_chat_template(chat, tokenize=False, add_generation_prompt=True))
# 输出：
# &lt;|user|&gt;
# Hello, how are you?&lt;/s&gt;
# &lt;|assistant|&gt;
# I'm doing great. How can I help you today?&lt;/s&gt;
# &lt;|user|&gt;
# I'd like to show off how chat templating works!&lt;/s&gt;
# &lt;|assistant|&gt;
</code></pre>
<p>这里就是多出了最后一行 <code>&lt;|assistant|&gt;</code>，提示模型应该开始补全 assistant 的部分了。</p>
<h2>LLM (LLMEngine)</h2>
<p>在 nano-vllm 中，<code>LLM</code> 就是 <code>LLMEngine</code> 包装了一下名字，便于阅读（但在 vllm 中并非这样，需要注意）：</p>
<pre><code># nanovllm/llm.py
from nanovllm.engine.llm_engine import LLMEngine

class LLM(LLMEngine):
    pass
</code></pre>
<p>所以看看 <code>LLMEngine</code>：</p>
<ul>
<li>共有六个方法，<code>__init__</code>，<code>exit</code>，<code>add_request</code>，<code>step</code>，<code>is_finished</code>，<code>generate</code>，其中重要的有 <code>__init__</code>、<code>generate</code></li>
<li>类成员五个 <code>ps</code>，<code>events</code>，<code>model_runner</code>，<code>tokenizer</code>，<code>scheduler</code>，其中核心成员为 <code>model_runner</code> 和 <code>scheduler</code></li>
</ul>
<h3><code>__init__</code></h3>
<p>先看 <code>__init__</code> 方法。接受一系列参数，范围在 <code>Config</code> 类的 field 内，所以我们先看看 <code>Config</code> 类：</p>
<pre><code>import os
from dataclasses import dataclass
from transformers import AutoConfig

@dataclass
class Config:
    model: str
    max_num_batched_tokens: int = 16384
    max_num_seqs: int = 512
    max_model_len: int = 4096
    gpu_memory_utilization: float = 0.9
    tensor_parallel_size: int = 1
    enforce_eager: bool = False
    hf_config: AutoConfig | None = None
    eos: int = -1
    kvcache_block_size: int = 256
    num_kvcache_blocks: int = -1

    def __post_init__(self):
        assert os.path.isdir(self.model)
        assert self.kvcache_block_size % 256 == 0
        assert 1 &lt;= self.tensor_parallel_size &lt;= 8
        self.hf_config = AutoConfig.from_pretrained(self.model)
        self.max_model_len = min(self.max_model_len, self.hf_config.max_position_embeddings)
        assert self.max_num_batched_tokens &gt;= self.max_model_len
</code></pre>
<p>这里面一些关键的配置：</p>
<ul>
<li><code>max_num_batched_tokens</code>: 同时处理的最大 token 数量</li>
<li><code>max_num_seqs</code>: 同时处理的最大请求数量</li>
<li><code>max_model_len</code>: 最大对话长度</li>
<li><code>gpu_memory_utilization</code>: GPU 利用率</li>
<li><code>tensor_parallel_size</code>: 张量并行的规模</li>
<li><code>enforce_eager</code>: 是否使用 eager mode，与之相对的是 graph mode</li>
<li><code>kvcache_block_size</code>: 存储 kv cache 时的块大小</li>
<li><code>num_kvcache_blocks</code>: kv cache 块数量</li>
</ul>
<p>注意 <code>__post_init__</code> 这个方法，它会在 dataclass 修饰器之后运行，可以用来参数校验以及动态调整成员值等</p>
<p>然后看初始化逻辑：首先为<a href="https://zhuanlan.zhihu.com/p/622212228">张量并行</a>启动若干个进程</p>
<pre><code>self.ps = [] # 所有子进程对象，方便管理（终止、join 等）
self.events = [] # 所有事件对象，用于进程间同步
ctx = mp.get_context("spawn") # 得到多进程上下文，使用 spawn 模式来避免 fork 时的 CUDA 问题
for i in range(1, config.tensor_parallel_size): # 注意是从 1 开始，因为当前进程（父进程）0 占用了一个张量并行的位置
    event = ctx.Event()
    process = ctx.Process(target=ModelRunner, args=(config, i, event)) # 创建子进程，将会运行 ModelRunner(config, i, event)
    process.start()
    self.ps.append(process)
    self.events.append(event) # 收集子进程和事件对象
</code></pre>
<p>然后初始化 model_runner、tokenizer、scheduler：</p>
<pre><code>self.model_runner = ModelRunner(config, 0, self.events) # 注意这里需要初始化当前进程的 model_runner，最后一个参数传入的是 self.events 列表而非单个事件，这是因为 rank 0 负责协调，能访问所有事件
self.tokenizer = AutoTokenizer.from_pretrained(config.model, use_fast=True)
config.eos = self.tokenizer.eos_token_id
self.scheduler = Scheduler(config)
</code></pre>
<p>最后使用 <code>atexit</code> 包将 <code>self.exit</code> 方法注册为一个清理函数，保证程序退出时资源正常释放：</p>
<pre><code>atexit.register(self.exit)
</code></pre>
<h3><code>exit</code></h3>
<p>上面提到注册了一个清理函数 <code>self.exit</code>，下面来简单看看这个函数做了什么：</p>
<pre><code>def exit(self):
    self.model_runner.call("exit")
    del self.model_runner
    for p in self.ps:
        p.join()
</code></pre>
<p>只做了两件事情，一是让 <code>model_runner</code> 退出并释放 <code>model_runner</code>，二是将所有子进程对象进行 <code>join</code> 方法保证结束。</p>
<h3><code>add_request</code></h3>
<p>然后再来看看如何向 <code>LLMEngine</code> 中添加请求，这个功能由 <code>add_request</code> 方法实现：</p>
<pre><code>def add_request(self, prompt: str | list[int], sampling_params: SamplingParams):
    if isinstance(prompt, str):
        prompt = self.tokenizer.encode(prompt)
    seq = Sequence(prompt, sampling_params)
    self.scheduler.add(seq)
</code></pre>
<p>整体逻辑很好理解，我们来看一下 <code>Sequence</code> 这个类，定义在 <code>nanovllm/engine/sequence.py</code> 中。一个该类的对象包含了一个请求的完整信息。这个类先用到了一个辅助类 <code>SequenceStatus</code>，是一个枚举类，有 <code>WAITING</code>，<code>RUNNING</code>，<code>FINISHED</code> 三个状态。</p>
<p>这个类有两个全类共享变量 <code>block_size</code> 和 <code>counter</code>，前者暂时不考虑（至少目前为止我们还用不到它，之后再分析），后者用于计算序列 id。<code>__init__</code> 方法如下：</p>
<pre><code>def __init__(self, token_ids: list[int], sampling_params = SamplingParams()):
    self.seq_id = next(Sequence.counter)
    self.status = SequenceStatus.WAITING
    self.token_ids = copy(token_ids)
    self.last_token = token_ids[-1]
    self.num_tokens = len(self.token_ids)
    self.num_prompt_tokens = len(token_ids)
    self.num_cached_tokens = 0
    self.block_table = []
    self.temperature = sampling_params.temperature
    self.max_tokens = sampling_params.max_tokens
    self.ignore_eos = sampling_params.ignore_eos
</code></pre>
<p>从成员的名称即可看出该成员的用处，还是很容易理解的。接着看看几个需要注意的类方法或 property。对于 <code>num_completion_tokens</code> 方法，使用到的成员变量 <code>num_tokens</code> 和 <code>num_prompt_tokens</code> 在这个序列还没有被处理的时候是相等的，此时的 <code>num_completion_tokens</code> 就是 0；当模型生成完毕后，<code>num_tokens</code> 增加，此时得到的 <code>num_completion_tokens</code> 就是最终生成回复的 token 数量。</p>
<h3><code>__getstate__</code> &amp; <code>__setstate__</code></h3>
<p>然后我们来看 <code>__getstate__</code> 和 <code>__setstate__</code> 这两个方法（参考 <a href="https://stackoverflow.com/questions/1939058/simple-example-of-use-of-setstate-and-getstate">https://stackoverflow.com/questions/1939058/simple-example-of-use-of-setstate-and-getstate</a>），它们用于 pickle 模块进行 <code>loads</code> 和 <code>dumps</code></p>
<pre><code>def __getstate__(self):
    return (self.num_tokens, self.num_prompt_tokens, self.num_cached_tokens, self.block_table,
            self.token_ids if self.num_completion_tokens == 0 else self.last_token)

def __setstate__(self, state):
    self.num_tokens, self.num_prompt_tokens, self.num_cached_tokens, self.block_table = state[:-1]
    if self.num_completion_tokens == 0:
        self.token_ids = state[-1]
    else:
        self.last_token = state[-1]
</code></pre>
<p>它们在 <code>nanovllm/engine/model_runner.py</code> 中的 <code>ModelRunner</code> 类的 <code>read_shm</code> 和 <code>write_shm</code> 方法中（作为 <code>*args</code> 的一个组成部分）使用</p>
<pre><code># read_shm
method_name, *args = pickle.loads(self.shm.buf[4:n+4])
# write_shm
data = pickle.dumps([method_name, *args])
n = len(data)
self.shm.buf[0:4] = n.to_bytes(4, "little")
self.shm.buf[4:n+4] = data
</code></pre>
<p>最后我们回到 <code>block_size</code> 这个成员变量，它是 paged attention 的一个参数，表示一个块的大小（不了解 paged attention 的话可以看下<a href="https://zhuanlan.zhihu.com/p/638468472">这篇知乎专栏</a>相关的部分），那么剩下的 <code>num_cached_blocks</code>、<code>num_blocks</code>、<code>last_block_num_tokens</code> 等成员函数的功能就显而易见了。</p>
<h3><code>generate</code></h3>
<p>从 <code>add_request</code> 中可以看出来，每一个 prompt 都需要对应一个 SamplingParams 对象，联想当在实际推理时，我们只会指定一次 SamplingParams 成员的值，不难推测一定在某个地方会创建一个 list 的成员值相同的 SamplingParams 对象。这正在 <code>LLMEngine</code> 的 <code>generate</code> 方法中实现了：</p>
<blockquote>
<p>由此我们可以知道，完全可以为一批请求分别给不同的 SamplingParams，来实现同时处理多用户不同性质（采样温度等）的请求</p>
</blockquote>
<pre><code># nanovllm/engine/llm_engine.py, line 59
def generate(
    self,
    prompts: list[str] | list[list[int]],
    sampling_params: SamplingParams | list[SamplingParams],
    use_tqdm: bool = True,
) -&gt; list[str]:
    if use_tqdm:
        pbar = tqdm(total=len(prompts), desc="Generating", dynamic_ncols=True)
    if not isinstance(sampling_params, list):
        sampling_params = [sampling_params] * len(prompts) # 这里处理不是列表的情况，默认所有序列生成参数相同
    for prompt, sp in zip(prompts, sampling_params):
        self.add_request(prompt, sp) # 调用上一部分提到的 add_request 方法
    outputs = {}
    prefill_throughput = decode_throughput = 0.
    while not self.is_finished():
        t = perf_counter()
        output, num_tokens = self.step() # step 方法，执行一步推理，我们在下一部分来看
        if use_tqdm:
            if num_tokens &gt; 0: # 约定大于 0 时是 prefill，小于 0 时是 decode
                prefill_throughput = num_tokens / (perf_counter() - t)
            else:
                decode_throughput = -num_tokens / (perf_counter() - t)
            pbar.set_postfix({
                "Prefill": f"{int(prefill_throughput)}tok/s",
                "Decode": f"{int(decode_throughput)}tok/s",
            })
        # 下面开始处理返回值，由于这些值和 step 方法相关，建议先看 step 的实现
        for seq_id, token_ids in output:
            outputs[seq_id] = token_ids
            if use_tqdm:
                pbar.update(1)
    outputs = [outputs[seq_id] for seq_id in sorted(outputs)]
    outputs = [{"text": self.tokenizer.decode(token_ids), "token_ids": token_ids} for token_ids in outputs]
    if use_tqdm:
        pbar.close()
    return outputs
</code></pre>
<h3><code>step</code></h3>
<p>接下来看 <code>step</code> 方法：</p>
<pre><code># nanovllm/engine/llm_engine.py, line 48
def step(self):
    seqs, is_prefill = self.scheduler.schedule() # scheduler 负责规划每一步需要向前推理的请求，同时返回一个是否处于 prefill 阶段的 bool 值
    token_ids = self.model_runner.call("run", seqs, is_prefill) # 让实际模型跑一步得到新的 token ids
    self.scheduler.postprocess(seqs, token_ids) # 后处理
    outputs = [(seq.seq_id, seq.completion_token_ids) for seq in seqs if seq.is_finished] # 只返回已经完成的请求
    num_tokens = sum(len(seq) for seq in seqs) if is_prefill else -len(seqs) # 这里如果是 decode 阶段则会返回一个负数，和上面的 generate 方法中对应的 if-else 判断语句逻辑相符
    return outputs, num_tokens
</code></pre>
<p>这里 <code>is_prefill</code> 是用于判断对应的请求是否处于 prefill 阶段。这个阶段是指请求是否是第一次被处理，此时推理一步需要将 prompt 中所有 token 都计算 kv cache 并生成第一个 token，和之后的每一步只需计算新的一个 token 的 kv 有很大不同，前者需要一次性进行大量计算（compute-bound），而后者则需要少量多次计算（memory-bound）。针对这两种截然不同的特性需要有不同的优化方法，这是后话，有机会再写。</p>
<p>另外 <code>scheduler</code> 的 <code>postprocess</code> 方法和 <code>schedule</code> 方法我们暂时不清楚，就暂且放过，等到后面读 <code>scheduler</code> 相关的部分再来回顾。</p>
<blockquote>
<p>至此我们对 <code>LLMEngine</code> 的了解就足够了，接下来看看它的重要组件：<code>ModelRunner</code> 和 <code>Scheduler</code></p>
</blockquote>
<h2><code>ModelRunner</code></h2>
<p>在读 <code>ModelRunner</code> 定义之前，我们先统计一下它在 <code>LLMEngine</code> 中出现的地方：</p>
<pre><code># nanovllm/engine/llm_engine.py

# __init__
for i in range(1, config.tensor_parallel_size):
    event = ctx.Event()
    process = ctx.Process(target=ModelRunner, args=(config, i, event))
    process.start()
    self.ps.append(process)
    self.events.append(event)
self.model_runner = ModelRunner(config, 0, self.events)

# exit
self.model_runner.call("exit")
del self.model_runner

# step
token_ids = self.model_runner.call("run", seqs, is_prefill)
</code></pre>
<p>可以大致发现一共使用了它的 init、call、exit 三个功能，其中 exit 和 run 都是通过 <code>call</code> 方法间接调用的。我们来看看它的所有类方法：</p>
<pre><code># nanovllm/engine/model_runner.py
class ModelRunner:
    def __init__(self, config: Config, rank: int, event: Event | list[Event]):
        ...
    def exit(self):
        ...
    def loop(self):
        ...
    def read_shm(self):
        ...
    def write_shm(self, method_name, *args):
        ...
    def call(self, method_name, *args):
        ...
    def warmup_model(self):
        ...
    def allocate_kv_cache(self):
        ...
    def prepare_block_tables(self, seqs: list[Sequence]):
        ...
    def prepare_prefill(self, seqs: list[Sequence]):
        ...
    def prepare_decode(self, seqs: list[Sequence]):
        ...
    def prepare_sample(self, seqs: list[Sequence]):
        ...
    def run_model(self, input_ids: torch.Tensor, positions: torch.Tensor, is_prefill: bool):
        ...
    def run(self, seqs: list[Sequence], is_prefill: bool) -&gt; list[int]:
        ...
    def capture_cudagraph(self):
        ...
</code></pre>
<p>相互之间的调用关系如下图所示：</p>
<p><img src="./img/ModelRunner.png" alt="" /></p>
<h3><code>__init__</code></h3>
<p>首先还是先看 <code>__init__</code>：</p>
<pre><code>def __init__(self, config: Config, rank: int, event: Event | list[Event]):
    # 先设置 config 值
    self.config = config
    hf_config = config.hf_config
    self.block_size = config.kvcache_block_size
    self.enforce_eager = config.enforce_eager
    self.world_size = config.tensor_parallel_size
    self.rank = rank
    self.event = event

    # 启动进程组并绑定 CUDA 设备、设置 torch 数据类型
    dist.init_process_group("nccl", "tcp://localhost:2333", world_size=self.world_size, rank=rank)
    torch.cuda.set_device(rank)
    default_dtype = torch.get_default_dtype()
    torch.set_default_dtype(hf_config.torch_dtype)
    torch.set_default_device("cuda")
    # 加载模型和 sampler
    self.model = Qwen3ForCausalLM(hf_config)
    load_model(self.model, config.model)
    self.sampler = Sampler()
    # 预热，避免首次真实推理时出现不确定的延迟或内存不足的问题
    self.warmup_model()
    self.allocate_kv_cache()
    # CUDA graph 相关，暂时不考虑
    if not self.enforce_eager:
        self.capture_cudagraph()
    # 设置默认设备和 dtype
    torch.set_default_device("cpu")
    torch.set_default_dtype(default_dtype)

    # 如果采用了张量并行，那么就使用共享内存来实现不同进程之间的通讯，在 read_shm 和 write_shm 中有使用，并且 rank=0 的进程负责创建，其他进程直接连接
    if self.world_size &gt; 1:
        if rank == 0:
            self.shm = SharedMemory(name="nanovllm", create=True, size=2**20)
            dist.barrier() # 先创建再等待其他进程同步
        else:
            dist.barrier() # 先同步得到 rank=0 进程创建的共享内存，然后再连接
            self.shm = SharedMemory(name="nanovllm")
            self.loop() # 其他进程进入 loop 开始循环，等待 rank=0 进程通过共享内存发送指令
</code></pre>
<h3><code>warmup_model</code></h3>
<p>在继续查看“预热”部分的代码前，我们需要先了解大模型的推理过程中的显存占用情况。</p>
<p>在推理过程中，显存占用主要由这四个部分组成：模型参数、激活值、KV cache、计算的中间结果。相较于训练过程，1. 只需要保存当前激活值，因为不需要反向传播；2. 不需要保存优化器状态。其中模型参数是固定的，而激活值随训练进行呈周期性变化、KV Cache 由具体的样本长度和回复长度决定、中间结果几乎不能提前知道占用多少。</p>
<p>另一方面，在第一次调用 GPU 内核、内存分配或 cuBLAS/cuDNN 算子时，CUDA 运行时会做很多隐式初始化，包括分配显存、加载内核、建立工作流。</p>
<p>这会带来一个问题：如果我们直接开始推理，就有可能由于不知道如何分配显存而导致显存不足、第一次推理的延迟非常不确定（可能会很高），直到后续推理才逐渐正常的现象。当然这个情况也会受到 FlashAttention、PagedAttention 等因素影响，这里不做过多讨论。</p>
<p>所以在推理开始前，一般都会首先进行一次”预热”，也就是 <code>warmup_model</code> 方法：</p>
<pre><code>def warmup_model(self):
    # 清空显存并重置峰值显存的记录
    torch.cuda.empty_cache()
    torch.cuda.reset_peak_memory_stats()
    # 使用最大负载来得到实际运行时的显存峰值占用
    max_num_batched_tokens, max_model_len = self.config.max_num_batched_tokens, self.config.max_model_len
    num_seqs = min(max_num_batched_tokens // max_model_len, self.config.max_num_seqs)
    seqs = [Sequence([0] * max_model_len) for _ in range(num_seqs)] # 由于这次推理只需要起到预热的效果就行了，所以可以任意赋值
    self.run(seqs, True) # 跑一遍refill 阶段
    torch.cuda.empty_cache() # 清空显存
</code></pre>
<p>然后根据得到的实际数值就可以来分配显存如何占用了，例如分配 KV Cache 的显存占用 <code>allocate_kv_cache</code> 方法。</p>
<p>从 <code>__init__</code> 方法可以看到，warmup_model 在以下时机被调用：</p>
<ul>
<li>模型加载完成后</li>
<li>KV缓存分配之前</li>
<li>CUDA图捕获之前</li>
</ul>
<p>这个时机很关键，因为它确保了后续的内存分配（特别是KV缓存）能够基于准确的内存使用情况进行计算，所以 warmup_model 是一个重要的初始化步骤，它确保模型在正式服务之前已经完全准备就绪，并为后续的内存管理提供了准确的基准。</p>
<h3><code>allocate_kv_cache</code></h3>
<p><code>allocate_kv_cache</code> 方法是在 推理前为模型分配 KV Cache。KV Cache（Key/Value 缓存）是保存注意力机制中历史 token 的中间结果的关键结构，具体为什么要缓存，缓存后如何使用可以参考 sglang 和 vllm 的论文。</p>
<pre><code>def allocate_kv_cache(self):
    config = self.config
    hf_config = config.hf_config
    free, total = torch.cuda.mem_get_info()
    used = total - free
    peak = torch.cuda.memory_stats()["allocated_bytes.all.peak"]
    current = torch.cuda.memory_stats()["allocated_bytes.all.current"]
    num_kv_heads = hf_config.num_key_value_heads // self.world_size
    block_bytes = 2 * hf_config.num_hidden_layers * self.block_size * num_kv_heads * hf_config.head_dim * hf_config.torch_dtype.itemsize
    config.num_kvcache_blocks = int(total * config.gpu_memory_utilization - used - peak + current) // block_bytes
    assert config.num_kvcache_blocks &gt; 0
    self.kv_cache = torch.zeros(2, hf_config.num_hidden_layers, config.num_kvcache_blocks, self.block_size, num_kv_heads, hf_config.head_dim)
    layer_id = 0
    for module in self.model.modules():
        if hasattr(module, "k_cache") and hasattr(module, "v_cache"):
            module.k_cache = self.kv_cache[0, layer_id]
            module.v_cache = self.kv_cache[1, layer_id]
            layer_id += 1
</code></pre>
<p>首先，代码通过 <code>torch.cuda.mem_get_info()</code> 和 <code>torch.cuda.memory_stats()</code> 获取 GPU 显存使用情况：</p>
<ul>
<li><code>free</code>, <code>total</code> 给出当前剩余和总显存大小；</li>
<li><code>used = total - free</code> 表示已经占用的显存；</li>
<li><code>peak</code> 和 <code>current</code> 分别表示历史峰值分配量和当前分配量。</li>
</ul>
<p>接下来，计算一个 单个 KV block 的大小：</p>
<p><code>block_bytes</code> 表示 存储一个 KV cache block 需要的显存字节数，且这个 block 是 跨所有层 (<code>num_hidden_layers</code>) 的。可以理解为：如果我要为模型的所有层再存一批 token 的 KV cache，那么这批数据总共占多少字节</p>
<pre><code>num_kv_heads = hf_config.num_key_value_heads // self.world_size
block_bytes = 2 * num_layers * block_size * num_kv_heads * head_dim * dtype_size
</code></pre>
<ul>
<li>2 对应 K 和 V 两个缓存；</li>
<li><code>num_layers</code> 是 <code>Transformer</code> 层数；</li>
<li><code>block_size</code> 是每个 block 能存多少 token；</li>
<li><code>num_kv_heads</code> 是分配到当前 rank 的 KV 头数（注意这里做了 // world_size 的切分）；</li>
<li><code>head_dim</code> 是每个注意力头的维度；</li>
<li>最后乘上 <code>dtype.itemsize</code> 得到字节数。</li>
</ul>
<p>然后，代码根据显存利用率参数 <code>config.gpu_memory_utilization</code>，结合 <code>total</code>，<code>used</code>，<code>peak</code>，<code>current</code> 估算出还能分配多少空间，除以 block_bytes 就得到最多能放下多少个 KV block。</p>
<pre><code># config.num_kvcache_blocks = (可用显存字节) // block_bytes
config.num_kvcache_blocks = int(total * config.gpu_memory_utilization - used - peak + current) // block_bytes
assert config.num_kvcache_blocks &gt; 0 # 断言必须 &gt; 0，保证至少能分配一块。
</code></pre>
<p>接着，通过 <code>torch.zeros</code> 在 GPU 上一次性申请一整个 KV cache 张量，shape 为：</p>
<pre><code>[2, num_layers, num_kvcache_blocks, block_size, num_kv_heads, head_dim]
</code></pre>
<ul>
<li>第一个维度 2：K 和 V 两个缓存；</li>
<li><code>num_layers</code>：每层 Transformer 各自有一份 KV cache；</li>
<li><code>num_kvcache_blocks</code>：每层能存多少个 block；</li>
</ul>
<p>其余维度对应注意力机制的 shape。</p>
<p>最后，遍历 <code>self.model.modules()</code>，把每一层的 <code>k_cache</code> 和 <code>v_cache</code> 指针指向这块大张量的切片，这样模型在推理时就能直接读写这块共享的 KV cache，而不需要单独为每层分配小块显存。</p>
<h3><code>loop</code></h3>
<p>然后来看看 <code>loop</code> 方法：</p>
<pre><code>def loop(self):
    while True:
        method_name, args = self.read_shm()
        self.call(method_name, *args)
        if method_name == "exit":
            break
</code></pre>
<p>从 <code>loop</code> 方法中可以看出来，这个类的核心逻辑是通过共享内存（shared memory, shm）和事件（event）机制，实现不同进程之间的远程方法调用。具体来说，loop 会不断地从共享内存中读取一个 <code>(method_name, args)</code>，然后调用对应的方法，如果方法名是 "exit"，就会跳出循环，结束进程。</p>
<pre><code>def read_shm(self):
    assert self.world_size &gt; 1 and self.rank
    self.event.wait()
    n = int.from_bytes(self.shm.buf[0:4], "little")
    method_name, *args = pickle.loads(self.shm.buf[4:n+4])
    self.event.clear()
    return method_name, args
</code></pre>
<p>接着看 <code>read_shm</code> 方法：它首先通过 <code>self.event.wait()</code> 阻塞等待，直到有新的请求被写入共享内存。然后前 4 个字节存储了序列化数据的长度 n，接着用 <code>pickle.loads</code> 从共享内存中恢复出 <code>(method_name, args)</code>。可以看到，这里要求 <code>world_size &gt; 1</code> 且当前进程不是 rank 0，也就是说，<code>read_shm</code> 专门为非主进程（worker）提供的接口，用于接收 rank 0 的控制消息。</p>
<pre><code>def write_shm(self, method_name, *args):
    assert self.world_size &gt; 1 and not self.rank
    data = pickle.dumps([method_name, *args])
    n = len(data)
    self.shm.buf[0:4] = n.to_bytes(4, "little")
    self.shm.buf[4:n+4] = data
    for event in self.event:
        event.set()
</code></pre>
<p>再看 <code>write_shm</code> 方法：与 <code>read_shm</code> 正好相对，这里要求 <code>rank == 0</code>，即只有主进程能写共享内存。它会先把 <code>(method_name, args)</code> 序列化成字节流写入共享内存，再通过 <code>event.set()</code> 唤醒所有等待的 worker 进程。这样，多个 worker 就能同时感知到新的调用。</p>
<pre><code>def call(self, method_name, *args):
    if self.world_size &gt; 1 and self.rank == 0:
        self.write_shm(method_name, *args)
    method = getattr(self, method_name, None)
    return method(*args)
</code></pre>
<p><code>call</code> 方法其实是上层的统一接口 —— 如果当前进程是主进程（rank == 0），那么在调用本地方法之前，会先把请求广播到共享内存中，让所有 worker 一起执行；如果是 worker，则直接调用本地方法。</p>
<h3><code>capture_cudagraph</code></h3>
<p>然后来看看 <code>capture_cudagraph</code> 函数</p>
<pre><code>@torch.inference_mode()
def capture_cudagraph(self):
    config = self.config
    hf_config = config.hf_config
    max_bs = min(self.config.max_num_seqs, 512)
    max_num_blocks = (config.max_model_len + self.block_size - 1) // self.block_size
    input_ids = torch.zeros(max_bs, dtype=torch.int64)
    positions = torch.zeros(max_bs, dtype=torch.int64)
    slot_mapping = torch.zeros(max_bs, dtype=torch.int32)
    context_lens = torch.zeros(max_bs, dtype=torch.int32)
    block_tables = torch.zeros(max_bs, max_num_blocks, dtype=torch.int32)
    outputs = torch.zeros(max_bs, hf_config.hidden_size)
    self.graph_bs = [1, 2, 4, 8] + list(range(16, max_bs + 1, 16))
    self.graphs = {}
    self.graph_pool = None

    for bs in reversed(self.graph_bs):
        graph = torch.cuda.CUDAGraph()
        set_context(False, slot_mapping=slot_mapping[:bs], context_lens=context_lens[:bs], block_tables=block_tables[:bs])
        outputs[:bs] = self.model(input_ids[:bs], positions[:bs])    # warmup
        with torch.cuda.graph(graph, self.graph_pool):
            outputs[:bs] = self.model(input_ids[:bs], positions[:bs])    # capture
        if self.graph_pool is None:
            self.graph_pool = graph.pool()
        self.graphs[bs] = graph
        torch.cuda.synchronize()
        reset_context()

    self.graph_vars = dict(
        input_ids=input_ids,
        positions=positions,
        slot_mapping=slot_mapping,
        context_lens=context_lens,
        block_tables=block_tables,
        outputs=outputs,
   )
</code></pre>
<p>在大规模语言模型推理中，每次调用 CUDA kernel 都会有 CPU-GPU 通信开销，而 CUDA Graph 可以将一系列 kernel 调用"录制"下来，后续直接重放（replay），大幅降低调用延迟。</p>
<p>首先，代码准备了推理所需的各种张量，并且都按照最大可能的 batch size 来分配：</p>
<ul>
<li><code>input_ids</code>：当前要推理的 token ID</li>
<li><code>positions</code>：每个 token 在序列中的位置</li>
<li><code>slot_mapping</code>：指向 KV cache 中具体存储位置的映射</li>
<li><code>context_lens</code>：每个序列的上下文长度</li>
<li><code>block_tables</code>：每个序列对应的 KV cache block 表</li>
<li><code>outputs</code>：模型输出的隐藏状态</li>
</ul>
<p>接下来，代码定义了一系列要预捕获的 batch size：[1, 2, 4, 8, 16, 32, 48, ...]。联想到实际推理时，不同时刻的 batch size 是动态变化的（有些序列完成了，有些刚开始），预先为常见的 batch size 录制 CUDA Graph，可以覆盖大部分推理场景。（比如对于 size 为 33 的情况，会选择 48 的图进行计算。</p>
<p>下面是核心的 CUDA Graph 捕获流程：</p>
<pre><code>for bs in reversed(self.graph_bs):  # 从大到小遍历 batch size，这样最大的内存需求会首先被满足
    graph = torch.cuda.CUDAGraph()
    set_context(...)  # 设置上下文信息
    outputs[:bs] = self.model(input_ids[:bs], positions[:bs])  # warmup，避免编译、初始化等一次性操作被记录，提高录制的 cuda graph 效率
    with torch.cuda.graph(graph, self.graph_pool): # 这个代码块中执行的所有CUDA操作都不会立即在GPU上运行，而是会被记录到 graph 对象中
        outputs[:bs] = self.model(input_ids[:bs], positions[:bs])  # capture
        if self.graph_pool is None:
            self.graph_pool = graph.pool() # 在成功捕获第一个 graph 后（即 bs 最大的 graph），保存其内存池，在后续继续捕获更小的 bs 的 graph 时共享，节约显存
        self.graphs[bs] = graph
        torch.cuda.synchronize() # 等待当前 graph 完全捕获
        reset_context()
</code></pre>
<p>最后，代码将所有输入张量保存到 <code>self.graph_vars</code> 中，这些张量会在后续 <code>replay</code> 时被复用——只需要修改张量内容，而不需要重新分配显存或重新构建计算图。</p>
<p>想要了解更多关于 cuda graph 及这段代码的含义，可以参考：<a href="https://www.bilibili.com/video/BV1fuYbzyEvk/">bilibili</a>、<a href="https://zhuanlan.zhihu.com/p/467466998">zhihu</a>。</p>
<h3><code>run</code></h3>
<p>下面可以进入到 <code>ModelRunner</code> 类最后的一部分了，即 <code>run</code> 方法及其调用的一部分方法。</p>
<pre><code>def run(self, seqs: list[Sequence], is_prefill: bool) -&gt; list[int]:
    input_ids, positions = self.prepare_prefill(seqs) if is_prefill else self.prepare_decode(seqs)
    temperatures = self.prepare_sample(seqs) if self.rank == 0 else None
    logits = self.run_model(input_ids, positions, is_prefill)
    token_ids = self.sampler(logits, temperatures).tolist() if self.rank == 0 else None
    reset_context()
    return token_ids
</code></pre>
<p><code>run</code> 函数是模型推理的主要入口点，它协调整个推理流程，包括输入准备、模型前向传播、采样和结果返回。</p>
<ul>
<li><code>seqs: list[Sequence]</code>：要处理的序列列表</li>
<li><code>is_prefill: bool</code>：标识当前是预填充阶段还是解码阶段</li>
</ul>
<p><code>run</code> 函数主要分为五个阶段（即一行代码一个阶段），此处先总述纲要，下面将进入函数一探究竟：</p>
<ol>
<li>首先是输入准备阶段，进行 <code>prepare_prefill</code> 或者 <code>prepare_decode</code>.</li>
<li>采样参数准备。只在主进程（rank 0）中准备采样参数，提取每个序列的温度参数用于控制生成的随机性，其他进程不需要采样参数，因为它们只参与模型计算。</li>
<li>模型前向传播。调用 <code>run_model</code> 执行实际的模型推理：根据 <code>is_prefill</code> 和其他条件选择执行模式（eager 模式或CUDA 图模式），返回每个序列下一个 token 的 logits 分布。</li>
<li>Token 采样,同样只在主进程中进行采样，使用 logits 和温度参数生成下一个 token，将结果转换为 Python 列表格式。</li>
<li>上下文清理，清理当前推理步骤的上下文信息。</li>
</ol>
<p>首先来看 <code>prepare_prefill</code> 和 <code>prepare_decode</code> 两个方法：</p>
<pre><code>def prepare_prefill(self, seqs: list[Sequence]):
    input_ids = []
    positions = []
    cu_seqlens_q = [0]
    cu_seqlens_k = [0]
    max_seqlen_q = 0
    max_seqlen_k = 0
    slot_mapping = []
    block_tables = None
    for seq in seqs:
        seqlen = len(seq)
        input_ids.extend(seq[seq.num_cached_tokens:])
        positions.extend(list(range(seq.num_cached_tokens, seqlen)))
        seqlen_q = seqlen - seq.num_cached_tokens
        seqlen_k = seqlen
        cu_seqlens_q.append(cu_seqlens_q[-1] + seqlen_q)
        cu_seqlens_k.append(cu_seqlens_k[-1] + seqlen_k)
        max_seqlen_q = max(seqlen_q, max_seqlen_q)
        max_seqlen_k = max(seqlen_k, max_seqlen_k)
        if not seq.block_table:    # warmup
            continue
        for i in range(seq.num_cached_blocks, seq.num_blocks):
            start = seq.block_table[i] * self.block_size
            if i != seq.num_blocks - 1:
                end = start + self.block_size
            else:
                end = start + seq.last_block_num_tokens
            slot_mapping.extend(list(range(start, end)))
    if cu_seqlens_k[-1] &gt; cu_seqlens_q[-1]:    # prefix cache
        block_tables = self.prepare_block_tables(seqs)
    input_ids = torch.tensor(input_ids, dtype=torch.int64, pin_memory=True).cuda(non_blocking=True)
    positions = torch.tensor(positions, dtype=torch.int64, pin_memory=True).cuda(non_blocking=True)
    cu_seqlens_q = torch.tensor(cu_seqlens_q, dtype=torch.int32, pin_memory=True).cuda(non_blocking=True)
    cu_seqlens_k = torch.tensor(cu_seqlens_k, dtype=torch.int32, pin_memory=True).cuda(non_blocking=True)
    slot_mapping = torch.tensor(slot_mapping, dtype=torch.int32, pin_memory=True).cuda(non_blocking=True)
    set_context(True, cu_seqlens_q, cu_seqlens_k, max_seqlen_q, max_seqlen_k, slot_mapping, None, block_tables)
    return input_ids, positions

def prepare_decode(self, seqs: list[Sequence]):
    input_ids = []
    positions = []
    slot_mapping = []
    context_lens = []
    for seq in seqs:
        input_ids.append(seq.last_token)
        positions.append(len(seq) - 1)
        context_lens.append(len(seq))
        slot_mapping.append(seq.block_table[-1] * self.block_size + seq.last_block_num_tokens  - 1)
    input_ids = torch.tensor(input_ids, dtype=torch.int64, pin_memory=True).cuda(non_blocking=True)
    positions = torch.tensor(positions, dtype=torch.int64, pin_memory=True).cuda(non_blocking=True)
    slot_mapping = torch.tensor(slot_mapping, dtype=torch.int32, pin_memory=True).cuda(non_blocking=True)
    context_lens = torch.tensor(context_lens, dtype=torch.int32, pin_memory=True).cuda(non_blocking=True)
    block_tables = self.prepare_block_tables(seqs)
    set_context(False, slot_mapping=slot_mapping, context_lens=context_lens, block_tables=block_tables)
    return input_ids, positions
</code></pre>
<p>在 <code>prepare_prefill</code> 的最开始准备了几个参数，实际上这些参数在 <code>Context</code> 类（<code>nanovllm/utils/context.py</code>）中也有出现，负责管理上下文：</p>
<pre><code>@dataclass
class Context:
    is_prefill: bool = False
    cu_seqlens_q: torch.Tensor | None = None
    cu_seqlens_k: torch.Tensor | None = None
    max_seqlen_q: int = 0
    max_seqlen_k: int = 0
    slot_mapping: torch.Tensor | None = None
    context_lens: torch.Tensor | None = None
    block_tables: torch.Tensor | None = None
</code></pre>
<p>这个类很简单，这里列举一些类成员的作用：</p>
<ul>
<li><code>cu_seqlens_k</code> 和 <code>cu_seqlens_q</code>：全称是 "Cumulative Sequence Lengths for Query/Key"，用于在 FlashAttention 等注意力计算方法中，标识每个序列在整个批次中的起始和结束位置，从 0 开始方便后续累加</li>
<li><code>max_seqlen_q</code>：当前批次中，需要计算的 Query 序列的最大长度</li>
<li><code>max_seqlen_k</code>：当前批次中，所有 Key/Value 序列（包括已缓存部分）的最大长度</li>
<li><code>slot_mapping</code>：在 PagedAttention 中用于将逻辑上的 token 位置映射到物理上的 KV 缓存块（KV Cache blocks）中的具体位置（slot）</li>
<li><code>block_tables</code>：存放每个序列的块表（Block Table）。块表记录了该序列的 KV 缓存被存储在哪些物理块中。</li>
</ul>
<p>回到 <code>prepare_prefill</code> 中，主要的处理逻辑在 for 循环中：</p>
<pre><code>for seq in seqs:
    seqlen = len(seq)
    input_ids.extend(seq[seq.num_cached_tokens:])
    positions.extend(list(range(seq.num_cached_tokens, seqlen)))
    seqlen_q = seqlen - seq.num_cached_tokens # 这里减掉已经缓存的 token 是因为只需要当前的 query 来计算 value，往前的 query 已经不需要了
    seqlen_k = seqlen
    cu_seqlens_q.append(cu_seqlens_q[-1] + seqlen_q) # 记录当前 seq 的终止位置，起始位置就是上一个 seq 的终止位置
    cu_seqlens_k.append(cu_seqlens_k[-1] + seqlen_k)
    max_seqlen_q = max(seqlen_q, max_seqlen_q) # 记录 query 最大长度
    max_seqlen_k = max(seqlen_k, max_seqlen_k)
    # block_table 为空则跳过，这是因为在 warmup_model 中还没有分配实际的缓存块表
    if not seq.block_table:    # warmup
        continue
    for i in range(seq.num_cached_blocks, seq.num_blocks): # 遍历未 cache 的物理块
        start = seq.block_table[i] * self.block_size
        if i != seq.num_blocks - 1: # 如果不是最后一个块
            end = start + self.block_size
        else:
            end = start + seq.last_block_num_tokens
        slot_mapping.extend(list(range(start, end))) # 添加计算出的物理 slot 索引范围
</code></pre>
<p>在这之后，将所有结果用于设置上下文 <code>set_context</code>：</p>
<pre><code>if cu_seqlens_k[-1] &gt; cu_seqlens_q[-1]:    # prefix cache
    block_tables = self.prepare_block_tables(seqs)
# pin_memory=True 表示使用锁页内存，可以加速从 CPU 到 GPU 的数据传输
# non_blocking=True 表示可以不用等待数据传输完成就可以继续执行后续代码
input_ids = torch.tensor(input_ids, dtype=torch.int64, pin_memory=True).cuda(non_blocking=True)
positions = torch.tensor(positions, dtype=torch.int64, pin_memory=True).cuda(non_blocking=True)
cu_seqlens_q = torch.tensor(cu_seqlens_q, dtype=torch.int32, pin_memory=True).cuda(non_blocking=True)
cu_seqlens_k = torch.tensor(cu_seqlens_k, dtype=torch.int32, pin_memory=True).cuda(non_blocking=True)
slot_mapping = torch.tensor(slot_mapping, dtype=torch.int32, pin_memory=True).cuda(non_blocking=True)
set_context(True, cu_seqlens_q, cu_seqlens_k, max_seqlen_q, max_seqlen_k, slot_mapping, None, block_tables)
</code></pre>
<p>关于锁页内存是什么，可以阅读 <a href="https://zhuanlan.zhihu.com/p/462191421">https://zhuanlan.zhihu.com/p/462191421</a>。</p>
<p><code>if</code> 语句用于处理 <a href="https://zhuanlan.zhihu.com/p/693556044">prefix cache</a>：当 key 的长度大于 query 的长度时，说明至少有一个序列使用了缓存，这会导致，这时调用 <code>prepare_block_tables</code>：</p>
<pre><code>def prepare_block_tables(self, seqs: list[Sequence]):
    max_len = max(len(seq.block_table) for seq in seqs) # 最大 block 块数
    block_tables = [seq.block_table + [-1] * (max_len - len(seq.block_table)) for seq in seqs] # 将较短的 block table 右补齐至最长的长度
    block_tables = torch.tensor(block_tables, dtype=torch.int32, pin_memory=True).cuda(non_blocking=True) # 得到对齐后的 block table
    return block_tables
</code></pre>
<p>注意只有在存在 prefix cache 的情况下， <code>prepare_block_tables</code> 才会被调用，这是因为此时才有序列的 kv cache 需要被读取，才需要对齐 block table；否则这个时候由于所有的序列都是第一次 step，并不存在 kv cache，更不可能存在 block table。</p>
<p>之后的几行就是在设置上下文，较为简单。<code>set_context</code> 函数如下：</p>
<pre><code>def set_context(is_prefill, cu_seqlens_q=None, cu_seqlens_k=None, max_seqlen_q=0, max_seqlen_k=0, slot_mapping=None, context_lens=None, block_tables=None):
    global _CONTEXT
    _CONTEXT = Context(is_prefill, cu_seqlens_q, cu_seqlens_k, max_seqlen_q, max_seqlen_k, slot_mapping, context_lens, block_tables)
</code></pre>
<p>利用全局变量 <code>_CONTEXT</code> 来设置当前的上下文。</p>
<p>继续来看 <code>prepare_decode</code>：</p>
<pre><code>def prepare_decode(self, seqs: list[Sequence]):
    input_ids = []
    positions = []
    slot_mapping = []
    context_lens = []
    for seq in seqs:
        input_ids.append(seq.last_token)
        positions.append(len(seq) - 1)
        context_lens.append(len(seq)) # 上下文总长度
        slot_mapping.append(seq.block_table[-1] * self.block_size + seq.last_block_num_tokens  - 1)
    input_ids = torch.tensor(input_ids, dtype=torch.int64, pin_memory=True).cuda(non_blocking=True)
    positions = torch.tensor(positions, dtype=torch.int64, pin_memory=True).cuda(non_blocking=True)
    slot_mapping = torch.tensor(slot_mapping, dtype=torch.int32, pin_memory=True).cuda(non_blocking=True)
    context_lens = torch.tensor(context_lens, dtype=torch.int32, pin_memory=True).cuda(non_blocking=True)
    block_tables = self.prepare_block_tables(seqs) # 此时必然已经分配了 kv blocks 了，所以一定会需要对齐
    set_context(False, slot_mapping=slot_mapping, context_lens=context_lens, block_tables=block_tables) # 由于 decode 阶段固定只生成一个 token，因此 cu_seqlens_q, max_seqlen_q 等变量不再需要
    return input_ids, positions
</code></pre>
<p>回想 decode 阶段每步只生成一个 token，上面的代码就不难理解了。和 <code>prepare_prefill</code> 相比，一个需要注意的点是 <code>slot_mapping</code> 在 <code>prepare_decode</code> 中为每一个序列存储一个整数，指向物理 kv cache 中用于存储新生成的 token 的 kv 值的 slot 位置：</p>
<ul>
<li><code>seq.block_table[-1]</code>：最后一个物理块的索引</li>
<li><code>seq.block_table[-1] * self.block_size</code>：最后一个物理块的起始位置</li>
<li><code>seq.block_table[-1] * self.block_size + seq.last_block_num_tokens  - 1</code>：新 token 对应的物理 slot 位置</li>
</ul>
<p>在准备好输入后（<code>input_ids</code> 和 <code>positions</code>），在 rank0（主进程）上将每一个 sample 的温度记录下来，以供之后对生成的 logits 进行采样：</p>
<pre><code>temperatures = self.prepare_sample(seqs) if self.rank == 0 else None
</code></pre>
<p>然后调用 <code>run_model</code> 进行一步推理，得到 logits：</p>
<pre><code>logits = self.run_model(input_ids, positions, is_prefill)
</code></pre>
<p>最后根据得到的 logits 和 temperatures 进行采样得到生成的新 token，随后立即重置上下文：</p>
<pre><code>token_ids = self.sampler(logits, temperatures).tolist() if self.rank == 0 else None
reset_context()
</code></pre>
<p>至此 <code>run</code> 函数已经分析完毕，只剩下 <code>run_model</code> 函数了。</p>
<h3><code>run_model</code></h3>
<pre><code>@torch.inference_mode()
def run_model(self, input_ids: torch.Tensor, positions: torch.Tensor, is_prefill: bool):
    if is_prefill or self.enforce_eager or input_ids.size(0) &gt; 512:
        return self.model.compute_logits(self.model(input_ids, positions))
    else:
        bs = input_ids.size(0)
        context = get_context()
        graph = self.graphs[next(x for x in self.graph_bs if x &gt;= bs)]
        graph_vars = self.graph_vars
        graph_vars["input_ids"][:bs] = input_ids
        graph_vars["positions"][:bs] = positions
        graph_vars["slot_mapping"].fill_(-1)
        graph_vars["slot_mapping"][:bs] = context.slot_mapping
        graph_vars["context_lens"].zero_()
        graph_vars["context_lens"][:bs] = context.context_lens
        graph_vars["block_tables"][:bs, :context.block_tables.size(1)] = context.block_tables
        graph.replay()
        return self.model.compute_logits(graph_vars["outputs"][:bs])
</code></pre>
<p>整个函数由一组 <code>if-else</code> 语句组成，其中当：</p>
<ul>
<li><code>is_prefill=True</code>：当前是 prefill 阶段，所有 prompt 的长度未定，导致计算图的形状是动态变化的，但 CUDA graph 要求形状固定，所以不能用 CUDA graph 加速</li>
<li><code>self.enforce_eager=True</code>：强制要求使用 eager mode</li>
<li><code>input_ids.size(0) &gt; 512</code>：batch size 过大，此时并没有为这么大的 batch size 预捕获 CUDA graph，所以只能用 eager mode</li>
</ul>
<p>这些情况下，不使用 CUDA graph 加速。否则会执行：</p>
<pre><code>bs = input_ids.size(0)
context = get_context()
graph = self.graphs[next(x for x in self.graph_bs if x &gt;= bs)] # 找到刚好超过 batch size 的预捕获值，下面的过程都是在“重现”当时捕获的场景
graph_vars = self.graph_vars
graph_vars["input_ids"][:bs] = input_ids
graph_vars["positions"][:bs] = positions
graph_vars["slot_mapping"].fill_(-1)
graph_vars["slot_mapping"][:bs] = context.slot_mapping
graph_vars["context_lens"].zero_()
graph_vars["context_lens"][:bs] = context.context_lens
graph_vars["block_tables"][:bs, :context.block_tables.size(1)] = context.block_tables
graph.replay() # 触发 GPU 执行之前被捕捉和编译好的完整计算图，相当于用一个 CPU 指令就启动了模型中所有层、所有 CUDA Kernel 的执行
return self.model.compute_logits(graph_vars["outputs"][:bs])
</code></pre>
<p>这里的 eager mode 可以简单的理解为“即时执行”的模式，CPU 会即时的启动模型用到的各种 CUDA kernel，也因此性能较 graph mode 更低，下面是一个简单的对比表格：</p>
<table>
<thead>
<tr>
<th>特性</th>
<th>Eager Mode (动态图)</th>
<th>Graph Mode (静态图 / CUDA Graphs)</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>执行方式</strong></td>
<td>逐行、即时执行</td>
<td>先编译、后一次性执行</td>
</tr>
<tr>
<td><strong>灵活性</strong></td>
<td><strong>极高</strong>，可处理动态形状和控制流</td>
<td><strong>很低</strong>，要求形状和流程固定</td>
</tr>
<tr>
<td><strong>性能</strong></td>
<td>较低，受 Python 开销影响大</td>
<td><strong>极高</strong>，CPU 开销非常小</td>
</tr>
<tr>
<td><strong>调试</strong></td>
<td><strong>容易</strong>，像普通 Python 程序</td>
<td>困难，是黑盒执行</td>
</tr>
<tr>
<td><strong><code>run_model</code> 中用途</strong></td>
<td><strong>Prefill 阶段</strong>、调试、处理<strong>不支持的批次大小</strong></td>
<td><strong>Decode 阶段</strong> (固定 batch size, 每次一个 token)</td>
</tr>
</tbody>
</table>
<blockquote>
<p>至此，我们已经完全了解了 <code>ModelRunner</code> 类了，对在准备好输入后模型是如何 'run' 起来的有了全面的认知，我们可以移步到 <code>LLMEngine</code> 的另一个重要类成员，<code>Scheduler</code> 去了。</p>
</blockquote>
<h2><code>Scheduler</code></h2>
<p>和 <code>ModelRunner</code> 中一样的，我们来看看 <code>LLMEngine</code> 中哪些地方使用了 <code>Scheduler</code> 这个类：</p>
<pre><code># __init__
self.scheduler = Scheduler(config)

# add_request
self.scheduler.add(seq)

# step
seqs, is_prefill = self.scheduler.schedule()
...
self.scheduler.postprocess(seqs, token_ids)

# is_finished
return self.scheduler.is_finished()
</code></pre>
<h3><code>__init__</code></h3>
<p>先来看看 <code>__init__</code> 方法（很简单，没什么好说的）：</p>
<pre><code>def __init__(self, config: Config): self.max_num_seqs = config.max_num_seqs
    self.max_num_batched_tokens = config.max_num_batched_tokens
    self.eos = config.eos
    self.block_manager = BlockManager(config.num_kvcache_blocks, config.kvcache_block_size)
    self.waiting: deque[Sequence] = deque() # 等待被执行的序列
    self.running: deque[Sequence] = deque() # 正在推理的序列
</code></pre>
<h3>辅助函数</h3>
<p><code>is_finished</code> 和 <code>add</code> 方法都很直观：</p>
<pre><code>def is_finished(self):
    return not self.waiting and not self.running

def add(self, seq: Sequence):
    self.waiting.append(seq)
</code></pre>
<h3><code>schedule</code></h3>
<p>最重要的是 <code>schedule</code> 方法：</p>
<pre><code>def schedule(self) -&gt; tuple[list[Sequence], bool]:
    # prefill
    scheduled_seqs = []
    num_seqs = 0
    num_batched_tokens = 0
    while self.waiting and num_seqs &lt; self.max_num_seqs:
        seq = self.waiting[0] # 得到 waiting 队列中第一个，但暂时没有移出 waiting 队列
        if num_batched_tokens + len(seq) &gt; self.max_num_batched_tokens or not self.block_manager.can_allocate(seq): # 如果超出最大限度，直接停止 schedule
            break
        num_seqs += 1
        self.block_manager.allocate(seq) # 分配新的 kv cache 块
        num_batched_tokens += len(seq) - seq.num_cached_tokens
        seq.status = SequenceStatus.RUNNING
        self.waiting.popleft() # 此时才将该请求移出 waiting 队列
        self.running.append(seq)
        scheduled_seqs.append(seq)
    # 由于上面进入 scheduled_seqs 的请求只能是 waiting 序列中的前若干项，所以必定是 prefill 阶段，返回的第二个参数为 True
    if scheduled_seqs:
        return scheduled_seqs, True

    # decode
    while self.running and num_seqs &lt; self.max_num_seqs:
        seq = self.running.popleft() # 取出 running 队列的最左边一个请求
        # 如果不能添加这个请求，则
        while not self.block_manager.can_append(seq):
            # 如果已经有在 decode 阶段的请求，则抢占 running 队列中的最右边一个
            if self.running:
                self.preempt(self.running.pop())
            # 否则“抢占”自己，即将 seq 放回 waiting 队列的最左边
            else:
                self.preempt(seq)
                break
        # 如果能够直接将 seq 添加进 running 队列
        else:
            num_seqs += 1
            self.block_manager.may_append(seq)
            scheduled_seqs.append(seq)
    assert scheduled_seqs
    self.running.extendleft(reversed(scheduled_seqs))
    return scheduled_seqs, False
</code></pre>
<p>这里 prefill 总是优先于 decode 的。这样做的原因是希望能够尽快给用户反馈，不用等待太久（TTFT 更小），代价是长序列的生成时间会变长（TBT 变大），且更容易出现所谓<strong>系统抖动</strong>的现象，即花费了大量时间在任务调度和上下文切换上，而不是在实际的推理计算上，从而降低了系统的整体有效吞吐量。</p>
<p>这里的调度策略是一个非常简单的形式，完全可以根据实际场景进行进一步的优化。</p>
<p><code>schedule</code> 方法还用到了 <code>preempt</code> 方法用于抢占资源，逻辑比较简单：</p>
<pre><code>def preempt(self, seq: Sequence):
    seq.status = SequenceStatus.WAITING
    self.block_manager.deallocate(seq)
    self.waiting.appendleft(seq)
</code></pre>
<p>另外还有 <code>postprocess</code> 方法，用于 <code>step</code> 后后处理请求和新生成的 tokens。</p>
<pre><code>def postprocess(self, seqs: list[Sequence], token_ids: list[int]) -&gt; list[bool]:
    for seq, token_id in zip(seqs, token_ids):
        seq.append_token(token_id) # 添加新 token
        if (not seq.ignore_eos and token_id == self.eos) or seq.num_completion_tokens == seq.max_tokens: # 生成了 eos 或达到最大 token 数量
            seq.status = SequenceStatus.FINISHED
            self.block_manager.deallocate(seq)
            self.running.remove(seq)
</code></pre>
<h2><code>BlockManager</code></h2>
<p>到目前为止，我们已经学习了 <code>LLMEngine</code> 和它的主要方法以及成员 <code>ModelRunner</code> 和 <code>Scheduler</code>，而在 <code>Scheduler</code> 中，还有一个非常重要的成员 <code>BlockManager</code>，接下来我们就继续补全这一块的代码</p>
<p><code>BlockManager</code> 肯定是要管理 <code>Block</code> 的，所以先看看 <code>Block</code> 这个类：</p>
<pre><code>class Block:

    def __init__(self, block_id):
        self.block_id = block_id
        self.ref_count = 0
        self.hash = -1
        self.token_ids = []

    def update(self, hash: int, token_ids: list[int]):
        self.hash = hash
        self.token_ids = token_ids

    def reset(self):
        self.ref_count = 1
        self.hash = -1
        self.token_ids = []
</code></pre>
<ul>
<li><code>block_id</code>：标识每个 block</li>
<li><code>ref_count</code>：当前 block 被引用了多少次（例如 prefix cache）</li>
<li><code>hash</code>：根据 <code>token_ids</code> 计算出的 hash 值，默认 -1 表示当前块尚未生成完成</li>
<li><code>token_ids</code>：当前 block 包含的 token ID 列表</li>
</ul>
<p>下面这张是 <code>BlockManager</code> 类的类方法之间的调用关系图，</p>
<p><img src="./img/BlockManager.svg" alt="" /></p>
<p>接下来我们依次阅读这个类的函数。</p>
<h3><code>__init__</code></h3>
<pre><code>def __init__(self, num_blocks: int, block_size: int):
    self.block_size = block_size
    self.blocks: list[Block] = [Block(i) for i in range(num_blocks)]
    self.hash_to_block_id: dict[int, int] = dict()
    self.free_block_ids: deque[int] = deque(range(num_blocks))
    self.used_block_ids: set[int] = set()
</code></pre>
<p>初始化函数，变量如其名。</p>
<h3>辅助函数</h3>
<pre><code>def can_allocate(self, seq: Sequence) -&gt; bool:
    return len(self.free_block_ids) &gt;= seq.num_blocks

def can_append(self, seq: Sequence) -&gt; bool:
    return len(self.free_block_ids) &gt;= (len(seq) % self.block_size == 1)
</code></pre>
<ul>
<li><code>can_allocate</code>：能否再分配一个 block</li>
<li><code>can_append</code>：是否能够再追加一个 token，<code>len(seq) % self.block_size == 1</code> 表示仅当恰好超出一个 token 时，才新分配一个 block</li>
</ul>
<h3><code>allocate</code></h3>
<p>然后我们来看看 <code>allocate</code> 这个函数，它主要用于为给定的 <code>seq</code> 分配 block</p>
<pre><code>def allocate(self, seq: Sequence):
    assert not seq.block_table # 没有被分配过
    h = -1                     # hash 值，默认 -1
    cache_miss = False         # 是否 cache 命中
    for i in range(seq.num_blocks):
        token_ids = seq.block(i) # 获取第 i 个 block 的 token_ids
        h = self.compute_hash(token_ids, h) if len(token_ids) == self.block_size else -1 # 如果 token_ids 可以构成一个完整的 block，则根据 token_ids 来为这个 block 计算一个 hash 值用于辨认，否则如果是不完整的块，则默认 hash 值为 -1
        block_id = self.hash_to_block_id.get(h, -1)
        if block_id == -1 or self.blocks[block_id].token_ids != token_ids:
            cache_miss = True
        if cache_miss: # 缓存不命中则新分配一个 block
            block_id = self.free_block_ids[0] # 注意这里只“引用”了第一个空闲块的 id，没有真正取出来
            block = self._allocate_block(block_id) # 在这里正式分配
        else: # 缓存命中
            seq.num_cached_tokens += self.block_size
            if block_id in self.used_block_ids: # 如果这个命中的块正在被其他请求使用
                block = self.blocks[block_id]
                block.ref_count += 1
            else: # 否则这个块是空闲的，需要通过 _allcate_block 来重新申明分配这个块
                block = self._allocate_block(block_id)
        # 如果是一个完整块，则更新 hash 值和 token_ids
        if h != -1:
            block.update(h, token_ids)
            self.hash_to_block_id[h] = block_id
        # 更新 seq 的 block table
        seq.block_table.append(block_id)
</code></pre>
<p>其中使用了 <code>_allocate_block</code> 函数来分配当前未被引用的块：</p>
<pre><code>def _allocate_block(self, block_id: int) -&gt; Block:
    block = self.blocks[block_id]
    assert block.ref_count == 0 # 分配这个块的前提是这个块当前是未被引用的
    block.reset()
    self.free_block_ids.remove(block_id)
    self.used_block_ids.add(block_id)
    return self.blocks[block_id]
</code></pre>
<p>以及 <code>compute_hash</code> 函数用来计算每个块的 hash 值：</p>
<pre><code>@classmethod
def compute_hash(cls, token_ids: list[int], prefix: int = -1):
    h = xxhash.xxh64()
    if prefix != -1:
        h.update(prefix.to_bytes(8, "little"))
    h.update(np.array(token_ids).tobytes())
    return h.intdigest()
</code></pre>
<p>这里需要注意的是 <code>prefix</code>，即<strong>链式哈希</strong>的使用。这是为了避免在不同请求中出现了相同缓存块时，缓存被错误的复用。在上面的 <code>allocate</code> 函数中，这个 <code>prefix</code> 会随 <code>for</code> 循环而更新，从而实现自动的链式哈希。</p>
<h3><code>deallocate</code></h3>
<p>继续来看 <code>deallocate</code> 函数：</p>
<pre><code>def deallocate(self, seq: Sequence):
    for block_id in reversed(seq.block_table): # 倒序释放
        block = self.blocks[block_id]
        block.ref_count -= 1
        if block.ref_count == 0:
            self._deallocate_block(block_id)
    seq.num_cached_tokens = 0
    seq.block_table.clear()
</code></pre>
<p>逻辑很简单，不多说。其中使用的 <code>_deallocate_block</code> 函数如下：</p>
<pre><code>def _deallocate_block(self, block_id: int) -&gt; Block:
    assert self.blocks[block_id].ref_count == 0
    self.used_block_ids.remove(block_id)
    self.free_block_ids.append(block_id)
</code></pre>
<h3><code>may_append</code></h3>
<p>最后来看 <code>may_append</code> 函数：</p>
<pre><code>def may_append(self, seq: Sequence):
    block_table = seq.block_table
    last_block = self.blocks[block_table[-1]]
    # 刚好多出一个 token，则新分配一个 block
    if len(seq) % self.block_size == 1:
        assert last_block.hash != -1
        block_id = self.free_block_ids[0]
        self._allocate_block(block_id)
        block_table.append(block_id)
    # 刚好满一个 block，则为该 block（seq 最后一个 block）更新 hash 值，并记录到 hash_to_block_id 表中
    elif len(seq) % self.block_size == 0:
        assert last_block.hash == -1
        token_ids = seq.block(seq.num_blocks-1)
        prefix = self.blocks[block_table[-2]].hash if len(block_table) &gt; 1 else -1
        h = self.compute_hash(token_ids, prefix)
        last_block.update(h, token_ids)
        self.hash_to_block_id[h] = last_block.block_id
    # 否则块处于中间态，hash 值必定是 -1
    else:
        assert last_block.hash == -1
</code></pre>
<p>这个函数用于 <code>Scheduler</code> 类的 <code>schedule</code> 函数的 decode 阶段，当 <code>self.block_manager.can_append(seq)=True</code> 时（即能够为 seq 新追加一个 token 时），检查是否需要为该 seq 的 block_table 新增加一个 block。</p>
<blockquote>
<p>至此，<code>BlockManager</code> 类的学习就结束了，nano-vllm 的绝大部分内容也已学习完成。</p>
</blockquote>
<h2>Model &amp; Sampler</h2>
<p>最后我们回到 <code>ModelRunner</code> 中的 <code>model</code> 和 <code>sampler</code> 两个成员。简单看看 <code>sampler</code>：</p>
<pre><code>class Sampler(nn.Module):

    def __init__(self):
        super().__init__()

    @torch.compile
    def forward(self, logits: torch.Tensor, temperatures: torch.Tensor):
        logits = logits.float().div_(temperatures.unsqueeze(dim=1))
        probs = torch.softmax(logits, dim=-1)
        sample_tokens = probs.div_(torch.empty_like(probs).exponential_(1).clamp_min_(1e-10)).argmax(dim=-1)
        return sample_tokens
</code></pre>
<p>然后是 <code>model</code> 的使用位置：</p>
<pre><code># __init__
self.model = Qwen3ForCausalLM(hf_config)
load_model(self.model, config.model)

# allocate_kv_cache
for module in self.model.modules():
    if hasattr(module, "k_cache") and hasattr(module, "v_cache"):
        module.k_cache = self.kv_cache[0, layer_id]
        module.v_cache = self.kv_cache[1, layer_id]
        layer_id += 1

# run_model
if is_prefill or self.enforce_eager or input_ids.size(0) &gt; 512:
    return self.model.compute_logits(self.model(input_ids, positions))
else:
    ...
    return self.model.compute_logits(graph_vars["outputs"][:bs])

# capture_cudagraph
outputs[:bs] = self.model(input_ids[:bs], positions[:bs])    # warmup
with torch.cuda.graph(graph, self.graph_pool):
    outputs[:bs] = self.model(input_ids[:bs], positions[:bs])    # capture
</code></pre>
<p>具体的模型定义见 <code>layers</code> 和 <code>models</code> 层，我把这部分和下面的 KV Cache 合并讲解。</p>
<h2>KV Cache</h2>
<blockquote>
<p>到目前为止，我们已经完整的阅读了一遍 nano-vllm 的 engine 部分的所有代码了，但是以上都是对各个“小部件”的分开阅读，没有一个全链路的理解。所以我觉得有必要从一个更“宏观”一点的视角来分析 nano-vllm 中的一些功能。那么就让我们从 KV Cache 开始吧。</p>
</blockquote>
<p>我们将 KV cache 的全链路拆开，从“内存布局/分配 $\to$ 前缀缓存与分页表 $\to$ 预填充(prefill) $\to$ 解码(decode) $\to$ 复用/淘汰与调度”这几个环节，逐步来讲是怎么运作的。</p>
<h3>Overview</h3>
<ul>
<li>统一的 KV Cache 池在 GPU 上一次性申请，按“块(block) × 槽(slot)”分页管理，所有层共享同一组 block 索引。</li>
<li>每个请求的上下文被切成固定大小的块（block_size，默认 256），其 KV 存在“全局块池”中，对应关系保存在每条序列的 <code>block_table</code> 里。</li>
<li>前缀复用依靠“滚动哈希 + 引用计数”：相同前缀的块映射到同一块 ID，并用 ref_count 做共享与回收。</li>
<li>执行期通过 <code>slot_mapping</code> 精确描述每一个 step 要写入哪些槽位，Triton kernel 把 K/V 写到对应 cache 槽；FlashAttention 读 cache 完成注意力。</li>
<li>调度器在“预填充/解码”两个阶段分别保证块资源可用，不够就抢占(preempt)并回收。</li>
</ul>
<h3>内存布局与分配</h3>
<blockquote>
<p><code>nanovllm/engine/model_runner.py::allocate_kv_cache</code></p>
</blockquote>
<p><code>kv_cache</code> 一次性在 GPU 上申请形状为 <code>self.kv_cache.shape = [2, num_layers, num_kvcache_blocks, block_size, num_kv_heads, head_dim]</code> 的一整块，然后通过 <code>warmup_model</code> 的结果估计出 KV Cache 的块数量，然后遍历模型模块，找到带 <code>k_cache/v_cache</code> 属性的注意力层，把每层的视图指到 <code>self.kv_cache[0 or 1, layer_id]</code> 上。这样所有层共享同一组 block 编号。</p>
<h3><code>BlockManager</code></h3>
<blockquote>
<p>nanovllm/engine/block_manager.py，配合 nanovllm/engine/sequence.py</p>
</blockquote>
<p>首先对每个序列切块，按照块大小 <code>Sequence.block_size=256</code>，<code>token_ids</code> 被切成若干块（最后一块可能不满）。这些块都由 <code>BlockManager</code> 负责管理分配和释放。用 prefix hash 来标识每一个块（<code>hash_to_block_id</code>），可以实现不同请求之间的复用（如 system prompt）。当且仅当一个块被 token 填满时，才会计算其 hash 值，否则默认为 -1。在 prefill 和 decode 阶段都会对新增加的 token 进行 block 分配或者抢占操作。</p>
<p>每一个 seq 的块分配结果记录在 <code>seq.block_table</code>中，映射是：逻辑块索引 $\to$ 全局块 ID。</p>
<h3><code>Context</code></h3>
<blockquote>
<p>nanovllm/utils/context.py、<code>nanovllm/layers/attention.py::store_kvcache</code></p>
</blockquote>
<p><code>Context</code> 类在之前就已经提到过了，用于管理模型推理的上下文。在 prefill 和 decode 阶段分别提供不同的数据：</p>
<ul>
<li>prefill： <code>cu_seqlens_q/k</code>、<code>max_seqlen_q/k</code>、<code>slot_mapping</code>、<code>block_tables</code>（如有前缀缓存）。</li>
<li>decode： <code>slot_mapping</code>、<code>context_lens</code>、<code>block_tables</code>。</li>
</ul>
<p>再回忆一下，<code>slot_mapping</code>表示了当前 step 会写入哪些槽位：</p>
<ul>
<li>prefill：对未命中的块（新块）或最后一块的新增部分生成连续的全局槽位索引。</li>
<li>decode：每条序列只写入当前 token 的槽位，即 <code>block_table[-1] * block_size + last_block_num_tokens - 1</code>。</li>
</ul>
<p>可以统一为如下形式 <code>slot = block_id * block_size + offset_in_block</code></p>
<p>在实际实现中，会通过 triton 实现的 <code>store_kvcache_kernel</code> 逐 token 把 <code>key/value</code> 写入 <code>k_cache/v_cache</code> 对应 <code>slot_mapping</code> 的位置。</p>
<pre><code># Attention.forward
k_cache, v_cache = self.k_cache, self.v_cache
if k_cache.numel() and v_cache.numel():
    store_kvcache(k, v, k_cache, v_cache, context.slot_mapping) # 存储新的 k(ey), v(alue)

# store_kvcache
def store_kvcache(key: torch.Tensor, value: torch.Tensor, k_cache: torch.Tensor, v_cache: torch.Tensor, slot_mapping: torch.Tensor):
    N, num_heads, head_dim = key.shape
    D = num_heads * head_dim
    assert key.stride(-1) == 1 and value.stride(-1) == 1
    assert key.stride(1) == head_dim and value.stride(1) == head_dim
    assert k_cache.stride(1) == D and v_cache.stride(1) == D
    assert slot_mapping.numel() == N
    store_kvcache_kernel[(N,)](key, key.stride(0), value, value.stride(0), k_cache, v_cache, slot_mapping, D)
</code></pre>
<p>这里 <code>store_kvcache_kernel</code> 如下：</p>
<pre><code>@triton.jit
def store_kvcache_kernel(
    key_ptr,
    key_stride,
    value_ptr,
    value_stride,
    k_cache_ptr,
    v_cache_ptr,
    slot_mapping_ptr,
    D: tl.constexpr,
):
    idx = tl.program_id(0)
    slot = tl.load(slot_mapping_ptr + idx)
    if slot == -1: return
    key_offsets = idx * key_stride + tl.arange(0, D)
    value_offsets = idx * value_stride + tl.arange(0, D)
    key = tl.load(key_ptr + key_offsets)
    value = tl.load(value_ptr + value_offsets)
    cache_offsets = slot * D + tl.arange(0, D)
    tl.store(k_cache_ptr + cache_offsets, key)
    tl.store(v_cache_ptr + cache_offsets, value)
</code></pre>
<p>将新的 k, v 储存到现有的 k_cache, v_cache 之后。之后模型运行时直接调用 flash_attn 提供的接口 <code>flash_attn_with_kvcache</code> 和 <code>flash_attn_varlen_func</code> 即可。</p>
<p>有关 triton，可以参考：<a href="https://zhuanlan.zhihu.com/p/527937835">https://zhuanlan.zhihu.com/p/527937835</a>；有关 flash attention，可以参考：<a href="https://zhuanlan.zhihu.com/p/668888063">https://zhuanlan.zhihu.com/p/668888063</a>。</p>
<h3>Prefill 阶段的数据流</h3>
<blockquote>
<p><code>nanovllm/engine/scheduler.py::schedule</code>（prefill 分支），<code>nanovllm/engine/model_runner.py::prepare_prefill</code></p>
</blockquote>
<ol>
<li><code>schedule</code> 函数在 waiting 队列里拉取序列，检查 token 总预算与块可用量（<code>block_manager.can_allocate</code>），满足则调用 <code>allocate(seq)</code> 完成块绑定和前缀复用判定。构造完毕后输入模型执行。</li>
<li><code>prepare_prefill</code> 函数构造张量，准备好如下 metadata 后设置全局上下文 <code>set_context(True, cu_seqlens..., slot_mapping, block_tables)</code>：</li>
</ol>
<ul>
<li><code>input_ids</code>：仅包含“未缓存的 token”的 ID（从 <code>seq.num_cached_tokens</code> 起）</li>
<li><code>positions</code>：对应 token 的绝对位置</li>
<li><code>cu_seqlens_q/k</code>、<code>max_seqlen_q/k</code>：变长前向所需</li>
<li><code>slot_mapping</code>：把需要新写入的 token 映射到全局槽位（遍历本序列从 <code>num_cached_blocks</code> 到 <code>num_blocks</code> 的块；最后一块只取已存在 token 数）</li>
<li><code>block_tables</code>：若 <code>sum_k &gt; sum_q</code>（即存在前缀块），则构建二维表，行是 batch 内不同序列的 <code>block_table</code>，用 -1 pad 到同长</li>
</ul>
<ol>
<li>在 <code>Attention</code> 模块：</li>
</ol>
<ul>
<li>如果已存在 <code>block_tables</code>（即存在前缀缓存）：把 <code>k/v</code> 指向缓存（<code>k_cache/v_cache</code>），调用 <code>flash_attn_varlen_func(..., block_table=block_tables)</code>；同时 Triton 内核按 <code>slot_mapping</code> 把新 token 的 <code>k/v</code> 追加写入缓存。</li>
<li>如果没有前缀缓存（warmup 之类）：会走纯 QKV 路径，但写 cache 的 slot_mapping 为空（N=0），不会写。</li>
</ul>
<p>对于第三点，可以回顾一下 <code>prepare_prefill</code> 中的这段逻辑：</p>
<pre><code>if not seq.block_table:    # warmup
    continue
for i in range(seq.num_cached_blocks, seq.num_blocks):
    start = seq.block_table[i] * self.block_size
    if i != seq.num_blocks - 1:
        end = start + self.block_size
    else:
        end = start + seq.last_block_num_tokens
    slot_mapping.extend(list(range(start, end)))
</code></pre>
<p>可以知道当在 warmup 等没有前缀缓存的情况下，<code>slot_mapping</code> 不会被处理，因而为空。</p>
<p>简单来说就是：</p>
<ol>
<li>新请求入队 $\to$ Scheduler prefill：</li>
<li><code>BlockManager.allocate</code> 复用满块、分配未命中块；</li>
<li><code>prepare_prefill</code> 生成 slot_mapping 与（如有）block_tables；</li>
<li>注意力用 <code>flash_attn_varlen</code> 读前缀块的 cache，同时写新 token 的 KV到 cache。</li>
</ol>
<h3>Decode 的数据流</h3>
<blockquote>
<p><code>nanovllm/engine/scheduler.py::schedule</code>（decode 分支），<code>model_runner.py::prepare_decode</code></p>
</blockquote>
<ol>
<li>在 <code>schedule</code> 函数中，在 running 队列上依次取出，如果即将需要新块但没有空闲块，则 <code>preempt</code> 其他/自己来释放块，否则调用 <code>block_manager.may_append(seq)</code> 做两件事：
<ul>
<li>如果当前长度 <code>len(seq) % block_size == 1</code>（即刚进入新块的第一个 token）：提前从 <code>free_block_ids</code> 列表中分配一个新 block_id，并加入 <code>seq.block_table</code>，确保即将写入的槽有实体块。</li>
<li>如果 <code>len(seq) % block_size == 0</code>（上个 step 正好写满了一整块）：此时为这个刚刚补满的块计算滚动哈希，写入 <code>hash_to_block_id</code>，使之成为可复用的前缀块。</li>
</ul>
</li>
<li>在 <code>prepare_decode</code> 函数中，同 <code>prepare_prefill</code> 一样准备一些 metadata，然后设置上下文 <code>set_context(False, slot_mapping, context_lens, block_tables)</code>：</li>
</ol>
<ul>
<li><code>input_ids</code>: 每条序列取 <code>last_token</code> 作为本步输入</li>
<li><code>positions</code>: <code>len(seq) - 1</code></li>
<li><code>slot_mapping</code>: 指向当前输入 token 的槽位，即 <code>block_table[-1] * block_size + last_block_num_tokens - 1</code></li>
<li><code>context_lens</code>: 每条序列当前上下文长度；</li>
<li><code>block_tables</code>: 和 prefill 一致，用于 FlashAttention 索引缓存</li>
</ul>
<ol>
<li>在 <code>Attention</code> 模块块中调用 <code>flash_attn_with_kvcache(q.unsqueeze(1), k_cache, v_cache, cache_seqlens=context_lens, block_table=block_tables, causal=True)</code> 来计算的同时，Triton 内核按 <code>slot_mapping</code> 将当前输入 token 的 K/V 加入到缓存。</li>
<li>取出 logits 采样下一个 token，<code>postprocess</code> 里 <code>seq.append_token(token_id)</code> 增长长度并在达到结束条件时回收块。</li>
</ol>
<p>简单来说就是：</p>
<ol>
<li>进入 decode 循环（每 step）：</li>
<li><code>BlockManager.may_append</code> 在需要时分配新块/为满块计算哈希；</li>
<li><code>prepare_decode</code> 设置当前输入 token 的 slot_mapping, context_lens, block_tables；</li>
<li>写当步 KV，注意力用 <code>flash_attn_with_kvcache</code> 读 cache；</li>
<li>采样得到下一个 token，append 进序列；</li>
<li>触发结束则回收块。</li>
<li>资源不足时 preempt 其他序列或自身，释放其块，稍后条件允许再继续。</li>
</ol>
<h3><code>preempt</code> 与调度策略</h3>
<blockquote>
<p>nanovllm/engine/scheduler.py 与 nanovllm/engine/block_manager.py</p>
</blockquote>
<ol>
<li><strong>正常结束</strong>：<code>postprocess</code> 发现到 EOS 或达到 <code>max_tokens</code>，标记 <code>FINISHED</code>，<code>block_manager.deallocate(seq)</code> 逆序减少各块 <code>ref_count</code>，为 0 的块归还空闲队列。</li>
<li><strong>decode 阶段资源紧张/抢占</strong>：<code>schedule</code> 的 decode 分支尝试 <code>can_append</code>，当需要新块而没有空闲块时，若还有其他运行中序列，先 <code>preempt(self.running.pop())</code>；否则 <code>preempt(seq)</code> 自己（把整条序列回到 waiting 并释放其所有块）。其中 <code>preempt</code> 会：
<ol>
<li>把序列状态改为 <code>WAITING</code></li>
<li><code>block_manager.deallocate(seq)</code> 释放全部块（ref--，为 0 的块回 free list）</li>
<li>把序列放回 waiting 队列头部，等待下一轮资源满足再来。</li>
</ol>
</li>
<li><strong>缓存一致性与碰撞</strong>：</li>
</ol>
<ul>
<li>复用基于“滚动哈希 + token_ids 全等检查”，发生哈希碰撞会被 token_ids 不相等拦下，走 miss 分配新块。</li>
<li>“未满块”没有哈希，不参与复用，直至填满后在下一次 <code>may_append</code> 时确立哈希。</li>
</ul>
<h3>多卡/Tensor Parallel</h3>
<blockquote>
<p>nanovllm/engine/model_runner.py</p>
</blockquote>
<ol>
<li><code>num_kv_heads</code> 在多卡下按 <code>// tensor_parallel_size</code> 切分，每个 rank 只承载自己的 KV 头分片，相应的 <code>k_cache/v_cache</code> 也是本 rank 局部的。</li>
<li>world_size &gt; 1 时 rank0 负责采样，进程间通过共享内存传递方法调用与采样结果；KV cache 管理逻辑在各 rank 本地执行。</li>
</ol>
<p>至此 KV cache 的分析也已结束，相信你也已经基本掌握了 nano-vllm 的绝大部分内容，如果还有其他想要补充的欢迎评论区留言~</p>
<p><img src="./img/banner.jpg" alt="" /></p>
]]></content>
        <author>
            <name>Kinnari</name>
            <uri>https://kinnari-blog.vercel.app/</uri>
        </author>
        <published>2025-10-17T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[第一段科研的总结和宣传]]></title>
        <id>https://kinnari-blog.vercel.app/posts/daro-summary/</id>
        <link href="https://kinnari-blog.vercel.app/posts/daro-summary/"/>
        <updated>2025-10-14T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[距离这篇工作结束已经过去一周多一些了，实际上是拖延症晚期拖到现在决定写一篇文章来记录一下这段科研。实际上这应该不算第一段，在此之前也进组打过...]]></summary>
        <content type="html"><![CDATA[<p>距离这篇工作结束已经过去一周多一些了，<s>实际上是拖延症晚期拖到现在</s>决定写一篇文章来记录一下这段科研。实际上这应该不算第一段，在此之前也进组打过工，但是由于种种原因并没有什么产出，更不用提以第一作者的身份发表文章，故而这些均不被我算作是第一段科研。我大概想从 idea、写作等方面来简单写写我的一些认知，其中不免有幼稚甚至错误的部分，还望能在评论中指出。</p>
<p><img src="./_image/pipeline.png" alt="" /></p>
<h2>基本情况</h2>
<p>因为我本人一直对于 RL + LLM 这一块，准确来说是 RLVR，十分感兴趣，<s>才不是因为这一块的数学公式比起其他同为灌水的方向更多</s>，所以在最初选题的时候就选择了这一方向。在我开始调研这一方向的时候（五月底六月初），其实已经发现 RL 文本推理其实已经卷麻了，一些过于简单的 idea 已经不足以抢着就能发论文，因此在整个工作期间我和带我的学长都处于一种焦虑的状态，担心我们的 idea 随时会被其他组抢发。</p>
<p>不得不提到的是，在开始这段经历前我曾拜读过大名鼎鼎的 <a href="https://arxiv.org/abs/2503.14476">DAPO</a> 这篇文章，被其中几个优雅且一看就有效的 trick 给迷上了，因此也萌发了超越 DAPO 的念头（这也是之后选用 DAPO 作为 baseline 之一的原因）。在最后的分析和提出统一损失框架的时候，也是 DAPO 给了我最初的灵感。</p>
<p>此外，这次合作的学长 <a href="https://github.com/TheRoadQaQ">TheRoadQAQ</a> 也是 nice 中的 nice，不仅本人做过文本的 RLVR，对这个方向了如指掌，而且在整个过程中，从最初 idea 的讨论，数据集的选择到最后的写作，都狠狠地 carry 了我一把，可以说没有他就没有这篇工作的最终完成，在这里感谢一波学长的大力帮助🙏。</p>
<h2>Idea</h2>
<p>最初我们希望能够通过课程学习的办法来加快 RL 训练的收敛速度，最终目标是缩短训练所需的时间，涉及到难度估计与更少的 rollout 次数。最开始我和学长的想法是通过在同一 batch 中以难度为依据重复采样的方式来进行更高效率的学习。当时一些工作，例如上面提到的 DAPO 以及 <a href="https://arxiv.org/pdf/2504.03380">https://arxiv.org/pdf/2504.03380</a> 等等文章，认为给模型训练中等难度的题目提升更大，因此我们使用了一个均值为 0.5（难度区间为 $[0,1]$），标准差为 0.1 的高斯分布进行采样，均值保证了中等难度的题目被采样到的概率最大，方差则由 $3\sigma$ 准则保证了采到的样本难度集中在 $[0.2, 0.8]$ 之间，正好剔除了难度过高和过低的样本，并且随着训练的进行，中等难度的题目集合也会发生变化，从而自动实现了课程学习的初衷。</p>
<p>初步的实验结果表明这样的采样策略是有效的，但是在更换数据集或模型后的表现却和 DAPO 差不多，有的甚至低于原始的 <a href="https://arxiv.org/pdf/2402.03300">GRPO</a>，并且在训练后期，因为模型能力大大提升，数据集中的样本多为完全没法做出和完全正确的两类，导致对一些难度适中的样本重复采样次数过多，训练极其容易崩溃。为了分析这种崩溃，我从样本优势（advantage，下面简称 adv）的角度进行了思考，发现所谓重复采样不过是给优势乘上了一个系数（后续称为权重）。GRPO 中原始的 adv 为：</p>
<p>$$
A=\frac{r-\mu}{\sigma}
$$</p>
<p>而对于一个被重复采样了 $n$ 次的样本来说，它的 adv 为：</p>
<p>$$
\hat{A}=\frac{r-\hat{\mu}}{\hat{\sigma}}, \text{ where } \hat{\mu}=\mu, \hat{\sigma} = \frac{\sigma}{\sqrt{n}}
$$</p>
<p>也就是说 $\hat{A}=\sqrt{n}A$。这意味着最终的 adv 为原始 adv 的离散加权形式，权重分别为 $\sqrt{1}, \sqrt{2}, \dots, \sqrt{n}, \dots$。而这样人为约定+随机获得的加权权重，显然不太可能碰巧就是最优加权形式，因此，我们将目光转向了如何设计一个最优的权重来提高每个样本的利用率，从而加速收敛。</p>
<p>又显然的，最优权重会受到多个因素的影响，包括模型能力、数据集、训练阶段等等，因此几乎不可能被人为设计出来。故我们认为这样的最优权重一定只能是模型自己学到的，而不能交给人类来做，所以这时我们的 idea 就变为了为每一个样本配置一个可学习的权重，在训练的反向传播时同步更新这一个权重，从而尝试获得最优权重。事实上，类似的想法在其他的一些论文中也有出现，例如用经典的 UCB 等方法来学习一个合适的权重。我们认为这些方法也有人为设计的成分，这是我们不希望的，我们希望这些权重能够完完全全由模型自己来学习。</p>
<p>想法虽然很美好，但是在实际的训练中难免会遇到一些问题。最严重的问题当属在一次完整的训练中，每一个样本被训练的次数可能不会超过个位数，例如训练 500 步，每一步的 batch size 为 128，数据集样本个数为 45k 的情况下，平均每个样本只会被利用 $500 * 128 / (45 * 1024) = 1.39$ 次，最差的情况下一个样本仅仅只会被模型“见到”一次，为它单独学习一个最优权重基本上可以说是天方夜谭。另一方面，从理论上来讲，仅仅是为每一个样本设置一个权重还不够，模型可能会学习到一些“窍门”来降低损失（称为权重 hack），例如如果 loss 都大于 0 的话，模型只需要学习每个权重为 -inf 即可让损失降到最低。因此，我们的思路又被打断了。</p>
<p>既然为每一个样本单独设置一个权重是不现实的，那么有没有一种妥协的办法，能够利用样本自带的某些性质，给样本分类后，给更大的类这个级别来加权呢？这时候我们将目光投向了 DAPO。在 DAPO 中，难度为 0 或者 1 的样本被完全剔除了，在他们的具体代码实现中，体现为筛除平均奖励（也就是样本在 rollout 中的通过率，下面都以通过率统称）为 0 或 1 的样本。这提醒我们，对每个 batch，在 rollout 结束并计算出奖励后，就可以根据通过率来为样本分组了！如果总共有 $K$ 次 rollout，那么就可以分类为通过率 ${0, 1/K, 2/K, \dots, K/K}$ 共 $K+1$ 类，也就只需要引入一个形状为 <code>(K+1,)</code> 的张量即可实现加权的效果。这样带来了若干好处：</p>
<ol>
<li>几乎在每一步，所有通过率的样本都会出现，从而对应的权重也能得到持续的更新，这巧妙地避免了上面我们提到的最大的问题。</li>
<li>实现这个加权只需要引入一个长度为 $K+1$ 的张量，与数据无关，与模型无关，几乎不会带来额外的计算开销和存储开销。</li>
<li>通过模型自主学习权重，能够隐式地实现课程学习，提高训练效率。</li>
</ol>
<p>这时候只剩下最后一个问题了，即上面提到的“权重 hack” 的问题。这个问题比较好解决，只需要添加一个惩罚项即可。记通过率 $\mu$ 对应的损失为 $\mathcal{L}<em>\mu$，权重为 $w</em>\mu$，惩罚项为 $N(w_\mu)$，那么最终的损失形式为：</p>
<p>$$
\begin{equation}
\hat{\mathcal{L}} = \sum_{\mu} \big(w_\mu \mathcal{L}<em>\mu + N(w</em>\mu)\big)
\end{equation}
$$</p>
<p>该如何得到这个惩罚项 $N(\cdot)$ 呢？我们希望这个项能够足够的合理，而不是简单粗暴地尝试几个常用的项后选择最好的一个使用。注意到这个项也会影响到最终的收敛状态，因此我们决定回到起点，从 GRPO 的损失开始进行分析，即分析 $\mathcal{L}_\mu$ 的性质。我们可以推导得到，在使用了 token-mean 作为聚合损失的方式的情况下（使用这种方式是因为在 <a href="https://arxiv.org/html/2508.08221v1">Lite PPO</a> 中的实验表明这种方式的效果更佳），GRPO 的损失可以表示为</p>
<p>$$
\begin{equation}
\begin{split}
&amp;\mathcal{L}<em>{GRPO}
= \mathbf{E}</em>{q\sim \mathcal{Q},{ o_{i} }<em>{i=1}^{K}\sim \pi</em>{\theta_{old}}(\cdot|q)} \left[\sum_{i=1}^{K} \frac{\lvert o_{i} \rvert}{L} f(A_i, o_{i})\right], \
&amp;f(A,o) = \begin{cases}
\min { r(\theta )A,(1+\epsilon)A },  &amp; A&gt;0 \
\max_{}{ r(\theta)A, (1-\epsilon)A }, &amp; A&lt;0
\end{cases}
\end{split}
\end{equation}
$$</p>
<p>其中 $\mathcal{Q}$ 为数据集，$\theta$ 为模型参数，$L$ 为回复长度之和，$f(\cdot)$ 为抽象出的 adv-clip 过程。具体推导细节见我们的论文 <a href="https://arxiv.org/abs/2510.09001">https://arxiv.org/abs/2510.09001</a>。在此基础上，结合<a href="https://en.wikipedia.org/wiki/Hoeffding%27s_inequality">霍夫丁不等式</a>，我们可以推导得到在训练的一步中 $\mathcal{L}_\mu$ 可以用下面的式子进行近似：</p>
<p>$$
\begin{equation}
\begin{split}
\mathcal{L}<em>\mu\approx \frac{\sum</em>{o\text{ is pos}} \lvert o\rvert - \sum_{o\text{ is neg}} \lvert o\rvert}{L} \sqrt{\mu(1-\mu)}
\end{split}
\end{equation}
$$</p>
<p>我们的实验结果表明这是一个非常优秀的近似，可以见论文中的 Figure 2，如下：</p>
<p><img src="./_image/loss_scale.png" alt="" /></p>
<p>从这个图中我们还不难发现一个事实，那就是 $\mathcal{L}_\mu$ 的大小是和模型、训练进度，以及最重要的 $\mu$ 相关的。从近似值中可以看出，这一结果也成立，因为涉及到了 $\sqrt{\mu(1-\mu)}$ 以及回复长度这两个值，而后者在几个工作中（如 Lite PPO）都有提到和 $\mu$ 紧密相关，我们也做了相应的实验来验证这个结论，如论文中的 Figure 3 以及附录中的 Figure 6。</p>
<p><img src="./_image/responce_length.png" alt="" /></p>
<p>那么很显然，在 GRPO 算法中，loss 值的大小会随 $\mu$ 而发生变化。对 Figure 2 的进一步观察可以得到，始终会有某些或某个 $\mu$ 对应的 $\mathcal{L}<em>\mu$ 会占据主导地位。这会导致模型过多的聚焦于某一种难度的样本，这显然是不妙的，很有可能会造成训练效率上的降低。于是如何设置惩罚项便明了了：我们需要这个惩罚项在收敛时能够让模型均匀的聚焦于每一种难度的样本上，体现在公式中即为 $w</em>\mu\mathcal{L}<em>\mu= C$，和 $\mu$ 无关。为了保持权重学习的自由，我们将这一目标作为最终的收敛条件，从而就能解出想要的惩罚项 $N(\dot)$。对损失 $\hat{\mathcal{L}}$ 求 $w</em>\mu$ 的偏导，得到：</p>
<p>$$
\nabla_{w_{\mu}}\hat{\mathcal{L}}=\mathcal{L}<em>{\mu} + N'(w</em>{\mu}) = 0.
$$</p>
<p>代入 $w_\mu\mathcal{L}<em>\mu= c$ 可以解得 $\mathcal{L}</em>\mu = Cw_\mu^{-1}$。又联想到 DAPO 中数据过滤的操作，将 $\mu=0,1$ 的样本筛除掉，那么就得到了损失的最终形式：</p>
<p>$$
\begin{equation}
\mathcal{L} = \sum_{\mu\ne0,1} (w_{\mu}\mathcal{L}<em>{\mu}-\ln w</em>{\mu}),
\end{equation}
$$</p>
<p><img src="./_image/pseudo-algorithm.png" alt="" /></p>
<p>至此，整个 idea 基本完成，具体算法见 Algorithm 1。还剩下我们关于若干个 RLVR 算法的统一框架的提出，这部分内容我只放结论在这里，具体推导细节见我们的论文 <a href="https://arxiv.org/abs/2510.09001">https://arxiv.org/abs/2510.09001</a>。我们可以将 GRPO、DAPO、Lite PPO 以及 Dr.GRPO 统一为如下形式：</p>
<p>$$
\begin{equation}
\begin{split}
\mathcal{L}<em>{BASE}=&amp; \mathbf{E}</em>{q\sim \mathcal{Q},{ o_{i} }<em>{i=1}^{K}\sim \pi</em>{\theta_{old}}(\cdot|q)} \left[\sum_{i=1}^{K} w_i\cdot \frac{\lvert o_{i} \rvert}{L} f(A_i, o_{i})\right].
\end{split}
\end{equation}
$$</p>
<p>其中权重 $w_i$ 的表达式如下：</p>
<p>&lt;div style="display: flex; justify-content: center;"&gt;</p>
<table>
<thead>
<tr>
<th><strong>Algorithm</strong></th>
<th><strong>Weight</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td>GRPO</td>
<td>$w_i = 1$</td>
</tr>
<tr>
<td>DAPO</td>
<td>$w_i=\mathbb{I}(0&lt;\mu&lt;1)$</td>
</tr>
<tr>
<td>Lite PPO</td>
<td>$w_{i} \propto \sqrt{\mu(1-\mu)}$</td>
</tr>
<tr>
<td>Dr.GRPO</td>
<td>$w_i \propto \sqrt{\mu(1-\mu)}$</td>
</tr>
</tbody>
</table>
<p>&lt;/div&gt;</p>
<p>由此可见，这几个方法本质上都是对 GRPO 的简单加权方式，并不具备成为最佳加权方式的可能性，我们的 DARO 方法的优越性最体现在这里。更详细的分析也请见论文。这里再放一些结果图</p>
<blockquote>
<p>[!DETAILS] 一些结果图表</p>
<p><img src="./_image/ablation.png" alt="" />
<img src="./_image/best_results.png" alt="" />
<img src="./_image/dynamic.png" alt="" />
<img src="./_image/result_dynamic.png" alt="" /></p>
</blockquote>
<h2>写作</h2>
<p>事实证明，我本人的英文写作能力有点过于烂了，常见的论文用语都记不住多少，让我直接用英文开写实在是有点吃力。因此我选择了相对轻松一点的写法，先用中文写一遍大致内容，然后再翻译成英文进行修改，最后英文版写完后再用 ai 进行润色。需要注意的点是在润色时可以同时让 ai 告诉自己每一处改的依据，这样既可以学到更“学术”的表达，也可以检查 ai 的修改是否正确。</p>
<p>这里我推荐看 <a href="https://aitour.site/research/GAMES003/">GAMES003</a> 里面关于写作的讲解，还是比较不错的，就我自己而言，总结出来的要点主要有</p>
<ol>
<li>写作需要有所谓 "flow" 的感觉，即一段话需要一个中心观点，这段话只需要讲好这个观点就行，不要一段话写太多东西；然后不同段的观点可以连缀成一个连贯的故事。</li>
<li>不要以一个做实验的人的角度写，而是站在读这篇论文的人的角度向他展示这篇文章做了什么，他能不能最舒适地明白这篇论文在干什么（即需要非常轻松的达到入脑的效果）。目前我还达不到这个境界😢</li>
<li>整篇文章的表格、图片的风格需要一致，最好是提前准备好一套令人舒适的调色方案</li>
<li>pipeline 类的图用 PPT 画，实操还是很容易上手的；其他图用 matplotlib</li>
<li>每一段文字的最后最好不要刚好多出一点点，这样会不美观</li>
<li>只写一遍是肯定不行的，至少要拿出一周的时间反复修改</li>
</ol>
<p>写作这一块我实在是太菜了，全靠学长的大力 carry 才能让论文出来，感动😭。</p>
<h2>其他</h2>
<p>在论文之外，我还有一些不太完整的想法，简单记录在下面。</p>
<p><strong>关于方向的选择</strong>：我个人觉得当前的大模型领域实在是太卷了，如果一个方向入场太晚难免会面临很多困难，比方说</p>
<ol>
<li>文本推理里面的各种算法被提出得太多，low-hanging fruit 被摘得太多，要提出新的且有用的东西的难度难免上升</li>
<li>入场太晚，在论文接收时也会遭遇和当时的热门小领域的区别对待，大家看多了都会厌倦，要是入场太晚难免会受到这种 debuff，比方说今年 NeuIPS 里面对投机解码的录取率就非常堪忧</li>
<li>入场太晚，基本上大家都在刷榜，你的方法即使有用且新颖，分数上打不过别的算法的话，也会天然被人低看一等</li>
</ol>
<p>当然入场太早也会有很多弊端和优点，这里不一一列举了。我没什么较好的权衡策略，毕竟我也还是在其中沉浮的一个分母，看不清整个局势。</p>
<p><strong>关于实验</strong>：最开始一定要调参！多调参，找到在所有模型和数据集上都合适的一组参数，然后再进行全量实验。虽然调参也会花费时间，但是能极大的避免一组参数在一个模型上正常训练，但另一个模型上崩溃的情况。</p>
<p><strong>关于投稿量</strong>：今年开始各大会议的投稿量激增，其中很大的一个原因就是太多人入场，提交甚至是大作业水平的论文来“碰运气”，更有甚者，开启了“斐波那契”式投稿的恶劣风气，只期望能够有审稿人网开一面或者判断错误将自己的论文接收。这些浮躁功利的风气实在是令人不适，我强烈反对这类行为。</p>
<p><strong>我对这篇论文的看法</strong>：我对这篇工作还是比较满意的，至少整个故事有理也有据，达到了让我满意的程度，我个人认为这不是一篇所谓水文（我永远不希望自己写出水文，更不会因为这是第一篇就放低自己的要求）。</p>
<p>最后，欢迎指正文章中的错误！arXiv 地址：<a href="https://arxiv.org/abs/2510.09001">https://arxiv.org/abs/2510.09001</a>，论文代码会在投稿结束后再公开。</p>
<p><img src="./_image/phoebe.png" alt="" /></p>
]]></content>
        <author>
            <name>Kinnari</name>
            <uri>https://kinnari-blog.vercel.app/</uri>
        </author>
        <published>2025-10-14T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[Fedora 环境搭建记录]]></title>
        <id>https://kinnari-blog.vercel.app/posts/envtools/fedora_setup/</id>
        <link href="https://kinnari-blog.vercel.app/posts/envtools/fedora_setup/"/>
        <updated>2025-09-11T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[[!WARNING] 这是在 Fedora 42 KDE Plasma Desktop 上的配置记录，所有指令请参考官网最新版本。sudo...]]></summary>
        <content type="html"><![CDATA[<blockquote>
<p>[!WARNING]</p>
<p>这是在 Fedora 42 KDE Plasma Desktop 上的配置记录，所有指令请参考官网最新版本。</p>
</blockquote>
<h2>更新系统包</h2>
<pre><code>sudo dnf update
</code></pre>
<h2>dnf 换源</h2>
<p>根据 https://mirrors.tuna.tsinghua.edu.cn/help/fedora/.</p>
<pre><code># backup
sudo mv /etc/yum.repos.d/fedora.repo /etc/yum.repos.d/fedora.repo.bak
sudo mv /etc/yum.repos.d/fedora-updates.repo /etc/yum.repos.d/fedora-updates.repo.bak
# modify
sed -e 's|^metalink=|#metalink=|g' \
    -e 's|^#baseurl=http://download.example/pub/fedora/linux|baseurl=https://mirrors.tuna.tsinghua.edu.cn/fedora|g' \
    -i.bak \
    /etc/yum.repos.d/fedora.repo \
    /etc/yum.repos.d/fedora-updates.repo
</code></pre>
<h2>字体</h2>
<p>我比较喜欢 MesloLGS nerd font 字体，前往 <a href="https://www.nerdfonts.com/font-downloads">https://www.nerdfonts.com/font-downloads</a> 下载得到 <code>Meslo.zip</code> 压缩包，解压后得到 <code>Meslo</code> 文件夹。</p>
<p>然后将 <code>Meslo</code> 文件夹移动到 <code>~/.local/share/fonts/</code> 或者 <code>/usr/share/fonts/</code> 目录下：</p>
<pre><code>sudo mv -r Meslo /usr/share/fonts
</code></pre>
<p>然后更新字体缓存：</p>
<pre><code>sudo fc-cache -fv
</code></pre>
<p>接着验证是否安装成功：</p>
<pre><code>fc-list | grep "Meslo"
</code></pre>
<h2>AppImage</h2>
<p>使用 appimagelauncher 管理 AppImage 文件。</p>
<pre><code>sudo dnf install appimagelauncher
</code></pre>
<h2>Flatpak</h2>
<p>根据 https://flatpak.org/setup/fedora 的提示进行配置。</p>
<pre><code>flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
</code></pre>
<p>然后下载 flatseal 管理权限（fedora 自带了 flatpak 应用权限管理，所以也可以不用装）</p>
<pre><code>sudo dnf install flatseal
</code></pre>
<h2>同步系统时间</h2>
<pre><code>timedatectl set-local-rtc 1 --adjust-system-clock
</code></pre>
<h2>安装常用软件</h2>
<h3>(neo)vim</h3>
<pre><code>sudo dnf install vim
sudo dnf install neovim
</code></pre>
<p>当然顺便也要卸载 nano 啦：</p>
<pre><code>sudo dnf remove nano
</code></pre>
<h3>clash-verge</h3>
<p>前往 https://clashverge.net/downloads/ 下载最新版本的 rpm 包。</p>
<pre><code>sudo dnf install ./Clash.Verge_2.3.0_amd64.rpm
</code></pre>
<p>然后前往配置文件设置代理。我主要使用 https://38nthu.xyz/（可能现在无法访问）。以及 https://mojie.app、https://ikuuu.org/ 作为应急情况。之后前往系统设置，添加自启动项。</p>
<h3>zsh</h3>
<pre><code># install
sudo dnf install zsh

# set zsh as the default shell
chsh -s $(which zsh)

# verify after re-login
echo $SHELL

# install oh-my-zsh
sudo dnf install curl git
sh -c "$(curl -fsSL https://raw.github.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"

# 安装 zsh-autosuggestions
git clone https://github.com/zsh-users/zsh-autosuggestions ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-autosuggestions
</code></pre>
<p>然后设置 oh-my-zsh. 启用一些插件</p>
<pre><code>plugins=(git zsh-autosuggestions z command-not-found colored-man-pages colorize conda-env copyfile copypath fzf python uv)
</code></pre>
<p>一些常用的快捷键（用 <code>bindkey</code> 查看，我不太确定上面的插件有没有进行设置，记得确认一下）：</p>
<table>
<thead>
<tr>
<th>快捷键</th>
<th>功能</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Ctrl + A</code></td>
<td>移动到行首</td>
</tr>
<tr>
<td><code>Ctrl + E</code></td>
<td>移动到行尾</td>
</tr>
<tr>
<td><code>Ctrl + U</code></td>
<td>删除光标前的内容</td>
</tr>
<tr>
<td><code>Ctrl + K</code></td>
<td>删除光标后的内容</td>
</tr>
<tr>
<td><code>Ctrl + W</code></td>
<td>删除光标前一个单词</td>
</tr>
<tr>
<td><code>Ctrl + T</code></td>
<td>使用 fzf 搜索文件</td>
</tr>
<tr>
<td><code>Alt + C</code></td>
<td>使用 fzf 查找文件夹并进入</td>
</tr>
</tbody>
</table>
<h3>fcitx5</h3>
<pre><code>sudo dnf install fcitx5
sudo dnf remove ibus
</code></pre>
<p>查看当前会话使用的是 wayland 还是 xorg:</p>
<pre><code>echo $XDG_SESSION_TYPE
</code></pre>
<p>对我而言是 wayland。 然后根据 <code>https://fcitx-im.org/wiki/Using_Fcitx_5_on_Wayland</code> 中的说明，将以下内容写入 <code>~/.profile</code> 或 <code>~/.zshrc</code>：</p>
<pre><code>export XMODIFIERS=@im=fcitx
</code></pre>
<p>然后修改文件 <code>~/.config/imsettings/xinputrc</code> (实际上是 <code>/etc/X11/xinit/xinput.d/xcompose.conf</code> 的软链接)，删除</p>
<pre><code>GTK_IM_MODULE=xim
QT_IM_MODULE=xim
</code></pre>
<p>这两行（需要的话记得备份）</p>
<p>然后添加到自启动应用程序中。重启以查看是否生效。然后配置中文输入法</p>
<pre><code>sudo dnf install fcitx5-rime
sudo dnf install fcitx5-chinese-addons
</code></pre>
<p>然后根据 https://github.com/iDvel/rime-ice 安装 rime-ice：</p>
<pre><code>cd ~/.local/share/fcitx5/
mv rime rime.bak # backup, not neccesary
git clone https://github.com/iDvel/rime-ice --depth=1 rime
</code></pre>
<blockquote>
<p>[!NOTE]
后来我切换到了更好用一些的白霜拼音，安装方法见 <a href="https://github.com/gaboolic/rime-frost">https://github.com/gaboolic/rime-frost</a>。另外稍微美化了一下输入法皮肤，使用的是 <a href="https://github.com/thep0y/fcitx5-themes-candlelight">https://github.com/thep0y/fcitx5-themes-candlelight</a> 的仿 MacOS 风格主题。</p>
</blockquote>
<h3>Git</h3>
<pre><code>git config --global set user.name xxx
git config --global set user.email xxx
</code></pre>
<p>当然我是保存了 <code>~/.gitconfig</code> 文件，直接复制到家目录就行了</p>
<h3>ssh</h3>
<p>根据 https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent 设置连接 github 的 ssh key。</p>
<pre><code>ssh-keygen -t ed25519 -C "2823324228@qq.com" # 保存至/home/kinnariya/.ssh/id_ed25519_github文件中
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519_github
</code></pre>
<p>然后去 https://github.com/settings/keys 添加公钥</p>
<pre><code>cat ~/.ssh/id_ed25519_github.pub
</code></pre>
<p>测试连接</p>
<pre><code>ssh -T git@github.com
</code></pre>
<p>自动启动 ssh-agent：向 ~/.zshrc 中加入</p>
<pre><code># ssh agent
# 启动 ssh-agent 并添加 ssh 密钥
if ! pgrep -u $USER ssh-agent &gt; /dev/null; then
    eval "$(ssh-agent -s)"
fi
ssh-add -l &amp;&gt;/dev/null || ssh-add ~/.ssh/id_ed25519_github &amp;&gt;/dev/null
</code></pre>
<h3>TLP</h3>
<pre><code>sudo dnf install tlp
flatpak install tlpui # 图形界面，方便设置
</code></pre>
<h3>Google Chrome &amp; edge</h3>
<p>Chrome 前往官网下载。登录后自动同步历史、插件等。</p>
<p>然后 Edge（根据 https://discussion.fedoraproject.org/t/install-edge/67186/4）:</p>
<pre><code>sudo rpm --import https://packages.microsoft.com/keys/microsoft.asc
sudo dnf config-manager addrepo --from-repofile='https://packages.microsoft.com/yumrepos/edge/config.repo'
sudo dnf update --refresh
sudo dnf install microsoft-edge-stable
</code></pre>
<p>然后卸载 Firefox：</p>
<pre><code>sudo dnf remove firefox
</code></pre>
<p>有时候会用到下载器，我用的 motrix，去官方下载：<a href="https://motrix.app/">https://motrix.app/</a></p>
<h3>Folo</h3>
<p>前往 https://github.com/RSSNext/Folo/releases 安装。</p>
<h3>QQ、微信</h3>
<p>可以选择 flatpak，也可以就用官网的（如果不care扫盘的风险的话）</p>
<p>微信在 wayland 下无法输入中文，可以通过修改环境变量来修补：将 /usr/share/applications/wechat.desktop 中的</p>
<pre><code>Exec=/usr/bin/wechat %U
</code></pre>
<p>修改为</p>
<pre><code>Exec=env QT_IM_MODULE=fcitx /usr/bin/wechat %U
</code></pre>
<p>即可</p>
<h3>Telegram</h3>
<pre><code>sudo dnf install telegram
</code></pre>
<h3>vscode &amp; cursor</h3>
<p>vscode 前往官网下载 rpm 包：https://code.visualstudio.com/</p>
<p>cursor 前往官网下载 appimage 格式：https://www.cursor.com/</p>
<h3>obsidian</h3>
<pre><code>flatpak install obsidian
</code></pre>
<p>如果需要同步的话，可以参考<a href="/posts/obsidian-sync">Obsidian-坚果云同步方法</a></p>
<h3>onedrive</h3>
<p>使用 onedriver https://github.com/jstaf/onedriver</p>
<pre><code>sudo dnf copr enable jstaf/onedriver
sudo dnf install onedriver
</code></pre>
<h3>yazi</h3>
<p>根据 <a href="https://yazi-rs.github.io/docs/installation/#copr">Installation | Yazi</a></p>
<pre><code>sudo dnf copr enable lihaohong/yazi
sudo dnf install yazi
</code></pre>
<p>然后向 <code>.zshrc</code> 中加入</p>
<pre><code>function yy() {
  local tmp="$(mktemp -t "yazi-cwd.XXXXXX")"
  yazi "$@" --cwd-file="$tmp"
  if cwd="$(cat -- "$tmp")" &amp;&amp; [ -n "$cwd" ] &amp;&amp; [ "$cwd" != "$PWD" ]; then
    builtin cd -- "$cwd"
  fi
  rm -f -- "$tmp"
}
</code></pre>
<h3>飞书</h3>
<p>前往官网下载 <a href="https://www.feishu.cn/download">下载飞书 App 及桌面客户端 - 飞书官网</a>。也可以直接使用网页版。</p>
<h3>Google docs</h3>
<p>先卸载 libreoffice：</p>
<pre><code>sudo dnf remove libreoffice*
</code></pre>
<p>然后去 https://docs.google.com/ 进行文档编写。</p>
<h3>WPS</h3>
<p>去官网 https://linux.wps.cn/ 下载。</p>
<p>如果你的系统是英文，而你希望 WPS 显示中文，可以按照 <a href="https://gist.github.com/YcSmile/039024e79b9d7dca38e80fdca7890620">https://gist.github.com/YcSmile/039024e79b9d7dca38e80fdca7890620</a> 进行修改。我采用的方案是修改 <code>/usr/bin/wps</code>，在文件开头添加：</p>
<pre><code>export LANGUAGE=zh_CN
</code></pre>
<h3>腾讯会议</h3>
<pre><code>flatpak install wemeet
</code></pre>
<h3>Minecraft</h3>
<p>根据官网信息（https://prismlauncher.org/download/）安装 Prism Launcher。</p>
<pre><code>sudo dnf copr enable g3tchoo/prismlauncher
sudo dnf install prismlauncher
</code></pre>
<p>或者直接使用 flatpak 安装：</p>
<pre><code>flatpak install PrismLauncher
</code></pre>
<h3>Docker</h3>
<p>根据 https://docs.docker.com/engine/install/fedora/：</p>
<pre><code>sudo dnf remove docker docker-client docker-client-latest docker-common docker-latest docker-latest-logrotate docker-logrotate docker-selinux docker-engine-selinux docker-engine

sudo dnf -y install dnf-plugins-core
sudo dnf-3 config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo

sudo dnf install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
</code></pre>
<p>因为我不希望 docker 自启动，所以设置 alias：</p>
<pre><code>alias docker_start="sudo systemctl start docker"
alias docker_stop="sudo systemctl stop docker &amp;&amp; sudo systemctl stop docker.socket"
</code></pre>
<p>否则可以使用</p>
<pre><code>sudo systemctl enable --now docker
</code></pre>
<p>每次使用 docker 都需要 root 权限，很麻烦，可以按照官方设置（https://docs.docker.com/engine/install/linux-postinstall/）：</p>
<pre><code>sudo groupadd docker
sudo usermod -aG docker $USER
</code></pre>
<p>然后重启，重启后运行</p>
<pre><code>newgrp docker
</code></pre>
<p>之后就可以用非 root 权限运行 docker 命令了。</p>
<p>然后配置 nvidia-docker-toolkit，因为 fedora 的 g++ 版本较新，nvidia 官方并没有支持，所以有时候就需要 docker 配置一下环境（也有其他方案，可以参考 <a href="#cccuda">CUDA</a> 一节）。首先安装 <a href="https://github.com/NVIDIA/nvidia-container-toolkit">NVIDIA Container Toolkit
</a></p>
<pre><code>curl -s -L https://nvidia.github.io/libnvidia-container/stable/rpm/nvidia-container-toolkit.repo | \
sudo tee /etc/yum.repos.d/nvidia-container-toolkit.repo

export NVIDIA_CONTAINER_TOOLKIT_VERSION=1.17.8-1
sudo dnf install -y \
    nvidia-container-toolkit-${NVIDIA_CONTAINER_TOOLKIT_VERSION} \
    nvidia-container-toolkit-base-${NVIDIA_CONTAINER_TOOLKIT_VERSION} \
    libnvidia-container-tools-${NVIDIA_CONTAINER_TOOLKIT_VERSION} \
    libnvidia-container1-${NVIDIA_CONTAINER_TOOLKIT_VERSION}
</code></pre>
<p>然后重启 docker:</p>
<pre><code>sudo systemctl restart docker
</code></pre>
<p>之后就可以拉取 nvidia <a href="https://hub.docker.com/r/nvidia/cuda/">官方镜像</a>了。</p>
<p>在更新到 Fedora 43 后，我的机器上出现了如下的错误：在运行 <code>sudo systemctl start docker</code> 时出现如下错误：</p>
<pre><code>Job for docker.service failed because the control process exited with error code.
See "systemctl status docker.service" and "journalctl -xe" for details.
</code></pre>
<p>并且运行 <code>journalctl -u docker.service --no-pager</code> 查看后，发现是如下的错误：</p>
<blockquote>
<p>[!DETAILS] 报错信息</p>
<pre><code>Nov 10 15:53:13 KinnariPC systemd[1]: docker.service: Scheduled restart job, restart counter is at 1.
Nov 10 15:53:13 KinnariPC systemd[1]: Starting docker.service - Docker Application Container Engine...
Nov 10 15:53:13 KinnariPC dockerd[9528]: time="2025-11-10T15:53:13.887495812+08:00" level=info msg="Starting up"
Nov 10 15:53:13 KinnariPC dockerd[9528]: time="2025-11-10T15:53:13.888210170+08:00" level=info msg="OTEL tracing is not configured, using no-op tracer provider"
Nov 10 15:53:13 KinnariPC dockerd[9528]: time="2025-11-10T15:53:13.892535235+08:00" level=info msg="CDI directory does not exist, skipping: failed to monitor for changes: no such file or directory" dir=/etc/cdi
Nov 10 15:53:13 KinnariPC dockerd[9528]: time="2025-11-10T15:53:13.892725369+08:00" level=info msg="detected 127.0.0.53 nameserver, assuming systemd-resolved, so using resolv.conf: /run/systemd/resolve/resolv.conf"
Nov 10 15:53:13 KinnariPC dockerd[9528]: time="2025-11-10T15:53:13.918611210+08:00" level=info msg="Creating a containerd client" address=/run/containerd/containerd.sock timeout=1m0s
Nov 10 15:53:13 KinnariPC dockerd[9528]: time="2025-11-10T15:53:13.974818713+08:00" level=info msg="[graphdriver] using prior storage driver: overlay2"
Nov 10 15:53:13 KinnariPC dockerd[9528]: time="2025-11-10T15:53:13.979294105+08:00" level=info msg="Loading containers: start."
Nov 10 15:53:13 KinnariPC dockerd[9528]: time="2025-11-10T15:53:13.998872994+08:00" level=info msg="Firewalld: docker zone already exists, returning"
Nov 10 15:53:15 KinnariPC dockerd[9528]: time="2025-11-10T15:53:15.133454060+08:00" level=warning msg="could not create bridge network for id 132a27e6b9e8c8081b1cd3fb19b88f6b7d29ccb12979eedef7c2dcae82a731de bridge name br-132a27e6b9e8 while booting up from persistent state: ZONE_CONFLICT: 'br-132a27e6b9e8' already bound to 'FedoraWorkstation'"
Nov 10 15:53:15 KinnariPC dockerd[9528]: time="2025-11-10T15:53:15.406023247+08:00" level=info msg="stopping event stream following graceful shutdown" error="&lt;nil&gt;" module=libcontainerd namespace=moby
Nov 10 15:53:15 KinnariPC dockerd[9528]: time="2025-11-10T15:53:15.406834926+08:00" level=info msg="stopping event stream following graceful shutdown" error="context canceled" module=libcontainerd namespace=plugins.moby
Nov 10 15:53:15 KinnariPC dockerd[9528]: failed to start daemon: Error initializing network controller: error creating default "bridge" network: ZONE_CONFLICT: 'docker0' already bound to 'FedoraWorkstation'
Nov 10 15:53:15 KinnariPC systemd[1]: docker.service: Main process exited, code=exited, status=1/FAILURE
Nov 10 15:53:15 KinnariPC systemd[1]: docker.service: Failed with result 'exit-code'.
Nov 10 15:53:15 KinnariPC systemd[1]: Failed to start docker.service - Docker Application Container Engine.
</code></pre>
</blockquote>
<p>根据 <a href="https://stackoverflow.com/questions/65213831/failed-to-start-daemon-error-initializing-network-controller-error-creating-de">https://stackoverflow.com/questions/65213831/failed-to-start-daemon-error-initializing-network-controller-error-creating-de</a>，只需要将 docker0 接口添加到 docker zone 即可：</p>
<pre><code>sudo firewall-cmd --permanent --zone=docker --change-interface=docker0
sudo firewall-cmd --reload
</code></pre>
<p>此时运行 <code>firewall-cmd --get-active-zones</code> 可以得到类似如下的输出：</p>
<pre><code>FedoraWorkstation (default)
  interfaces: wlp0s20f3 br-132a27e6b9e8
docker
  interfaces: docker0
</code></pre>
<h3>fzf</h3>
<p>配置几个好用的函数（alias）：</p>
<pre><code>alias fzf_process="ps aux | fzf --layout=reverse --border --height=80%"
open() {
  local target
  target=$(fzf \
    --preview '[[ -d {} ]] &amp;&amp; tree -C {} | head -100 || bat --style=numbers --color=always --line-range=:100 {} 2&gt;/dev/null || cat {} 2&gt;/dev/null' \
    --preview-window=right:60%:wrap \
    --height=80% \
    --layout=reverse \
    --border \
    --bind "ctrl-o:execute(nvim {})+abort")

  if [[ -n "$target" ]]; then
    nvim "$target"
  fi
}
</code></pre>
<h3>YesPlayMusic</h3>
<pre><code>flatpak install io.github.qier222.YesPlayMusic
</code></pre>
<p>如果只想播放本地音乐的话，可以用 elisa：</p>
<pre><code>sudo dnf install elisa
</code></pre>
<h3>Steam</h3>
<pre><code>sudo dnf install steam
</code></pre>
<h3>其他</h3>
<pre><code>sudo dnf install fastfetch tldr
sudo dnf install git-lfs
sudo dnf install aria2
sudo dnf install strace thefuck # 蒋炎岩 os 课上用了
sudo dnf install meld
flatpak install net.sapples.LiveCaptions

# lazygit, https://copr.fedorainfracloud.org/coprs/atim/lazygit/
sudo dnf copr enable atim/lazygit
sudo dnf install lazygit

# uv, https://github.com/astral-sh/uv
curl -LsSf https://astral.sh/uv/install.sh | sh

# KDE 自带的截图工具有点模糊，我换用了 flameshot
# 安装之后有 bug 的话，可以按照 https://github.com/flameshot-org/flameshot/issues/2364#issuecomment-3056083074 进行修复
# 以及需要重新在系统设置中绑定快捷键
sudo dnf install flameshot
sudo dnf rmeove spectacle

# 小玩具
sudo dnf install lolcat
</code></pre>
<h2>系统快捷键设置</h2>
<p>前往 Settings &gt; Keyboard &gt; Shortcuts 进行设置。</p>
<h2>美化</h2>
<p>我没做过多的美化，因为觉得原皮挺好看的。不过我安装了一些有用的插件：</p>
<ul>
<li><a href="https://github.com/tully-t/weather-widget-plus">weather-widget-plus</a>：天气预报小插件</li>
</ul>
<p><img src="./_image/fedora.png" alt="也是经典壁纸了哈哈" /></p>
<blockquote>
<p>如果想美化成 mac 风格，可以参考这个视频：<a href="https://www.bilibili.com/video/BV14shHzvEb4/">https://www.bilibili.com/video/BV14shHzvEb4/</a></p>
</blockquote>
<p>然后如果想要用一下 wallpaper engine，可以这样操作：</p>
<ol>
<li>安装 Steam，然后在 Steam 上安装 wallpaper engine，下载想要的壁纸</li>
<li>到系统设置 &gt; Appearance &amp; Style &gt; Wallpaper 中，选择 Get New Wallpapers，然后搜索 Wallpaper Engine for kde 并安装</li>
<li>然后克隆插件，安装依赖项后编译：</li>
</ol>
<pre><code># 克隆仓库并更新子模块
git clone https://github.com/catsout/wallpaper-engine-kde-plugin.git
cd wallpaper-engine-kde-plugin
git submodule update --init --force --recursive

# 安装依赖项
sudo dnf install qt6-qtwebsockets-devel extra-cmake-modules plasma-workspace-devel libplasma-devel mpv-libs mpv-devel lz4 lz4-devel

# 编译
cmake -B build -S . \
    -DCMAKE_BUILD_TYPE=Release \
    -DQT_MAJOR_VERSION=6 \
    -DUSE_PLASMAPKG=OFF \
    -DCMAKE_INSTALL_PREFIX=/usr \
    -DSPIRV_REFLECT_STATIC_LIB=ON \
    -DBUILD_QML=ON
cmake --build build
sudo cmake --install build
</code></pre>
<p>然后回到 Wallpaper 设置那里，选择 Wallpaper type 为 Wallpaper Engine for kde，根据提示选择 Library 路径为 <code>~/.local/share/Steam</code>，之后就可以选择壁纸啦。之后每一次更新 kde store 中的插件，都需要重新编译安装一次。</p>
<blockquote>
<p>上面的命令参考了 <a href="https://github.com/catsout/wallpaper-engine-kde-plugin#build-and-install">https://github.com/catsout/wallpaper-engine-kde-plugin#build-and-install</a> 和 <a href="https://github.com/catsout/wallpaper-engine-kde-plugin/issues/383#issuecomment-2025705401">https://github.com/catsout/wallpaper-engine-kde-plugin/issues/383#issuecomment-2025705401</a></p>
<p>如果重启电脑之后出现了 plasma 崩溃的情况，需要将 <code>~/.config/plasma-org.kde.plasma.desktop-appletsrc</code> 中所有 <code>WallpaperSource</code> 行删除</p>
</blockquote>
<blockquote>
<p>此外还可以尝试一下 <a href="https://github.com/YaLTeR/niri">niri</a> 以及配套预设 <a href="https://github.com/AvengeMedia/DankMaterialShell">DankMaterialShell</a>，我现在就正在使用😋</p>
</blockquote>
<h2>访问 Windows 文件</h2>
<p>有时候想直接访问 Windows 中的文件，但是每次开机后第一次访问都要输密码（udisk 导致的），稍显麻烦，根据 https://dynacont.net/documentation/linux/udisks2_polkit_Allow_unauthenticated_mounting/ 可以创建 <code>/etc/polkit-1/rules.d/10-udisks2.rules</code> 文件并写入：</p>
<pre><code>// See the polkit(8) man page for more information
// about configuring polkit.

// Allow udisks2 to mount devices without authentication
// for users in the "wheel" group.
polkit.addRule(function(action, subject) {
    if ((action.id == "org.freedesktop.udisks2.filesystem-mount-system" ||
         action.id == "org.freedesktop.udisks2.filesystem-mount") &amp;&amp;
        subject.isInGroup("wheel")) {
        return polkit.Result.YES;
    }
});
</code></pre>
<p>然后前往系统设置 &gt; Disks &amp; Cameras &gt; Device Auto-Mount，勾选需要自动挂载的分区即可，重启后生效。</p>
<pre><code>sudo dnf install
</code></pre>
<h2>多屏设置</h2>
<p>需要使用独显，才能获得较为流畅的多屏体验。安装驱动的方法见<a href="#cccuda">下面</a>。</p>
<h2>蓝牙设置</h2>
<p>fedora 42 的蓝牙存在自动断连的 bug，在 <a href="https://discussion.fedoraproject.org/t/bluetooth-is-extremely-bugged-out-in-f42-kde-also-in-f41-kde/158644/40">https://discussion.fedoraproject.org/t/bluetooth-is-extremely-bugged-out-in-f42-kde-also-in-f41-kde/158644/40</a> 等帖子中有所提及。在我的设备上，可以通过重启蓝牙服务来解决：</p>
<pre><code>sudo systemctl restart bluetooth
</code></pre>
<p>或者根据 <a href="https://discussion.fedoraproject.org/t/bluetooth-service-needs-to-be-restarted-to-work/156298/9">https://discussion.fedoraproject.org/t/bluetooth-service-needs-to-be-restarted-to-work/156298/9</a> 指出的，可以降级 bluez 包来解决这个问题：</p>
<pre><code>sudo dnf downgrade bluez
</code></pre>
<h2>环境配置</h2>
<h3>Python</h3>
<p>根据官网信息 <a href="https://www.anaconda.com/docs/getting-started/miniconda/install#linux-terminal-installer">Installing Miniconda - Anaconda</a> 安装 miniconda</p>
<pre><code>wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh
bash ~/Miniconda3-latest-Linux-x86_64.sh
</code></pre>
<p>然后设置 pip 源：</p>
<pre><code># 设置为清华源
pip config set global.index-url https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
# 设置代理，注意代理地址可能不一样 :doge:
pip config set global.proxy https://127.0.1:7890
</code></pre>
<p>设置 conda 源：</p>
<pre><code>channels:
  - defaults
  - conda-forge
  - nodefaults
show_channel_urls: true
default_channels:
  - https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main
  - https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/r
  - https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/msys2
custom_channels:
  conda-forge: https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud
  pytorch: https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud
  msys2: https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud
  bioconda: https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud
  menpo: https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud
  pytorch-lts: https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud
  simpleitk: https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud
  deepmodeling: https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/
channel_priority: flexible
</code></pre>
<p>然后安装 wandb（其他工具包同理，都可以用 uv 来装）：</p>
<pre><code>uv tool install wandb
# 登录
wandb login
</code></pre>
<h3>rust</h3>
<p>参考 <a href="https://developer.fedoraproject.org/tech/languages/rust/rust-installation.html">https://developer.fedoraproject.org/tech/languages/rust/rust-installation.html</a></p>
<pre><code>sudo dnf install cargo rust
</code></pre>
<p>或者使用 rust 官方的安装指导：</p>
<pre><code>sudo dnf install rustup
rustup-init
# 根据提示进行安装后
. "$HOME/.cargo/env"
</code></pre>
<p>这两者不能同时使用，官方推荐使用后者。</p>
<h3>C/C++/CUDA</h3>
<p>安装 xmake：</p>
<pre><code>sudo dnf install xmake
</code></pre>
<p>安装 CUDA（安装前记得备份系统）：</p>
<pre><code>sudo dnf install xorg-x11-drv-nvidia-cuda
</code></pre>
<p>然后重启电脑，进入终端，输入 <code>nvidia-smi</code> 检验是否安装成功。</p>
<p>前往 vscode，安装 clangd 插件。然后安装 clangd lsp：</p>
<pre><code>sudo dnf install clang-tools-extra
</code></pre>
<p>因为 nvidia 官方现在没有支持 Fedora 42 的 cudatoolkit 安装，所以我选择使用 conda 来安装（https://anaconda.org/nvidia/cuda-toolkit，也可以直接安装 Fedora 41 的，见下）：</p>
<pre><code>conda create -n cuda python=3.10.16
conda activate cuda
conda install nvidia/label/cuda-11.8.0::cuda-toolkit
</code></pre>
<p>运行</p>
<pre><code>nvcc -V
</code></pre>
<p>得到输出</p>
<pre><code>nvcc: NVIDIA (R) Cuda compiler driver
Copyright (c) 2005-2024 NVIDIA Corporation
Built on Thu_Mar_28_02:18:24_PDT_2024
Cuda compilation tools, release 12.4, V12.4.131
Build cuda_12.4.r12.4/compiler.34097967_0
</code></pre>
<p>编译以下 <code>hello.cu</code> 文件：</p>
<pre><code>#include &lt;stdio.h&gt;

__global__ void cuda_say_hello() {
  printf("Hello world, CUDA! %d\n", threadIdx.x);
}

int main() {
  printf("Hello world, CPU\n");
  cuda_say_hello&lt;&lt;&lt;1, 1&gt;&gt;&gt;();

  cudaError_t cudaerr = cudaDeviceSynchronize();
  if (cudaerr != cudaSuccess)
    printf("kernel launch failed with error \"%s\".\n",
           cudaGetErrorString(cudaerr));
  return 0;
}
</code></pre>
<p>编译并运行：</p>
<pre><code>nvcc hello.cu -o hello
./hello
</code></pre>
<p>得到输出：</p>
<pre><code>Hello world, CPU
Hello world, CUDA! 0
</code></pre>
<p>此时相关文件在 <code>$CONDA_PATH/targets/x86_64-linux/</code> 文件夹下，可以配置 <code>.clangd</code> 文件为：</p>
<pre><code>CompileFlags:
  Add: [
    "-L/home/kinnariya/miniconda3/envs/cuda/targets/x86_64-linux/lib",
    "-I/home/kinnariya/miniconda3/envs/cuda/targets/x86_64-linux/include"
  ]
</code></pre>
<p>这里我的 <code>$CONDA_PATH</code> 即为 <code>/home/kinnariya/miniconda3/envs/cuda/targets/x86_64-linux/</code>。重启 vscode，此时可以正常使用 clangd 来编写 cuda 文件了。</p>
<p>之后安装 g++，以支持 C++ 编译：</p>
<pre><code>sudo dnf install gcc-c++
</code></pre>
<p>也可以直接安装 cuda-toolkit（根据 https://developer.nvidia.com/cuda-downloads）：</p>
<pre><code>sudo dnf config-manager addrepo --from-repofile https://developer.download.nvidia.com/compute/cuda/repos/fedora41/x86_64/cuda-fedora41.repo
sudo dnf clean all
sudo dnf config-manager setopt cuda-fedora41-$(uname -m).exclude=nvidia-driver,nvidia-modprobe,nvidia-persistenced,nvidia-settings,nvidia-libXNVCtrl,nvidia-xconfig
sudo dnf -y install cuda-toolkit-12-9 # 注意 cuda-toolkit 的版本，这里我用的是 12.9，建议参考官方说明
</code></pre>
<p>由于 fedora 42 的 g++ 版本为 15，太新了，需要手动安装 g++14（参考 <a href="https://forum.level1techs.com/t/cuda-12-9-on-fedora-42-guide-including-getting-cuda-samples-running/230769">https://forum.level1techs.com/t/cuda-12-9-on-fedora-42-guide-including-getting-cuda-samples-running/230769</a>）</p>
<pre><code>sudo dnf install gcc14.x86_64 gcc14-c++.x86_64 cuda-nvcc-12-9
</code></pre>
<p>然后更新环境变量：</p>
<pre><code>export CUDAHOSTCXX=/usr/bin/g++-14
export CPATH=/usr/include/openmpi-x86_64:$CPATH
export PATH=$PATH:/usr/lib64/openmpi/bin
export CC=/usr/bin/gcc-14
export CXX=/usr/bin/g++-14
export NVCC_CCBIN=/usr/bin/g++-14
</code></pre>
<p>最后将所有库函数和 includes 路径添加到环境变量中</p>
<pre><code>export LD_LIBRARY_PATH=/usr/local/cuda-12.9/targets/x86_64-linux/lib:$LD_LIBRARY_PATH
export CPATH=/usr/local/cuda-12.9/targets/x86_64-linux/include:$CPATH
export PATH=/usr/local/cuda-12.9/bin:$PATH
</code></pre>
<p>还需要修改一些源代码：修改 <code>/usr/local/cuda-12.9/targets/x86_64-linux/include/crt/math_functions.h</code>：</p>
<pre><code>  *
  * \note_accuracy_double
  */
-extern __DEVICE_FUNCTIONS_DECL__ __device_builtin__ double                 sinpi(double x);
+extern __DEVICE_FUNCTIONS_DECL__ __device_builtin__ double                 sinpi(double x) noexcept (true);
 /**
  * \ingroup CUDA_MATH_SINGLE
  * \brief Calculate the sine of the input argument
@@ -2576,7 +2576,7 @@
  *
  * \note_accuracy_single
  */
-extern __DEVICE_FUNCTIONS_DECL__ __device_builtin__ float                  sinpif(float x);
+extern __DEVICE_FUNCTIONS_DECL__ __device_builtin__ float                  sinpif(float x) noexcept (true);
 /**
  * \ingroup CUDA_MATH_DOUBLE
  * \brief Calculate the cosine of the input argument
@@ -2598,7 +2598,7 @@
  *
  * \note_accuracy_double
  */
-extern __DEVICE_FUNCTIONS_DECL__ __device_builtin__ double                 cospi(double x);
+extern __DEVICE_FUNCTIONS_DECL__ __device_builtin__ double                 cospi(double x) noexcept (true);
 /**
  * \ingroup CUDA_MATH_SINGLE
  * \brief Calculate the cosine of the input argument
@@ -2620,7 +2620,7 @@
  *
  * \note_accuracy_single
  */
-extern __DEVICE_FUNCTIONS_DECL__ __device_builtin__ float                  cospif(float x);
+extern __DEVICE_FUNCTIONS_DECL__ __device_builtin__ float                  cospif(float x) noexcept (true);
 /**
  * \ingroup CUDA_MATH_DOUBLE
  * \brief  Calculate the sine and cosine of the first input argument
</code></pre>
<p><code>.clangd</code> 文件的设置同上，将所有 <code>/home/kinnariya/miniconda3/envs/cuda/targets/x86_64-linux/</code> 修改为 <code>/usr/local/cuda-12.9</code> 即可。</p>
<h3>OpenMP</h3>
<p>使用如下命令来检查是否安装完成：</p>
<pre><code>echo | cpp -fopenmp -dM | grep -i open
</code></pre>
<p>如果 OpenMP 已经正确配置，那么会看到类似下面的输出：</p>
<pre><code>#define _OPENMP 201511
</code></pre>
<p>其中 <code>201511</code> 为版本号。在编译相关文件时，需要加上 <code>-fopenmp</code> 选项，例如对下面的例子：</p>
<pre><code>// hello_openmp.c
#include &lt;stdio.h&gt;
#include &lt;omp.h&gt;

int main() {
    #pragma omp parallel
    {
        int thread_id = omp_get_thread_num();
        printf("Hello from thread %d\n", thread_id);
    }
    return 0;
}
</code></pre>
<p>使用</p>
<pre><code>gcc -fopenmp hello_openmp.c -o hello_openmp
</code></pre>
<p>来编译。</p>
<h3>博客</h3>
<pre><code>sudo dnf install node npm pnpm yarn
</code></pre>
<h3>LaTex</h3>
<p>我采用 medium 安装，包含了常用的宏包。</p>
<pre><code># sudo dnf install texlive-scheme-basic
sudo dnf install texlive-scheme-medium
# sudo dnf install texlive-scheme-full
</code></pre>
<p>发现中文有报错</p>
<pre><code>File `ctex.sty' not found.LaTeX
</code></pre>
<p>于是安装 ctex 宏包：</p>
<pre><code>sudo dnf install texlive-ctex
</code></pre>
<p>其他使用中发现缺失的宏包同理。</p>
<h3>Java</h3>
<pre><code>sudo dnf install java-21-openjdk java-21-openjdk-devel
sudo dnf install maven # 包管理器
</code></pre>
<p>然后配置 maven 源。进入 <code>~/.m2/</code> 目录，创建 <code>settings.xml</code> 文件，并写入：</p>
<pre><code>&lt;settings&gt;
    &lt;mirrors&gt;
        &lt;mirror&gt;
            &lt;id&gt;aliyun&lt;/id&gt;
            &lt;name&gt;aliyun&lt;/name&gt;
            &lt;mirrorOf&gt;central&lt;/mirrorOf&gt;
            
            &lt;url&gt;https://maven.aliyun.com/repository/central&lt;/url&gt;
        &lt;/mirror&gt;
    &lt;/mirrors&gt;
&lt;/settings&gt;
</code></pre>
<p>然后我用 IDEA 來写 Java，而且用了 ideavim 插件，配置如下：</p>
<blockquote>
<p>[!DETAILS] <code>~/.ideavimrc</code></p>
<pre><code>let g:vimrc_author='Kinnari'
let g:vimrc_email='jy_zhou@sjtu.edu.cn'

"--插件
set easymotion
set surround
set multiple-cursors

nnoremap &lt;A-j&gt; :m .+1&lt;CR&gt;==
nnoremap &lt;A-k&gt; :m .-2&lt;CR&gt;==
"inoremap &lt;A-k&gt; &lt;Esc&gt;:m .-2&lt;CR&gt;==gi
"inoremap &lt;A-j&gt; &lt;Esc&gt;:m .+1&lt;CR&gt;==gi
vnoremap &lt;A-j&gt; :m '&gt;+1&lt;CR&gt;gv=gv
vnoremap &lt;A-k&gt; :m '&lt;-2&lt;CR&gt;gv=gv


" ================================================================================================
" = Basic settings =====================================
" ================================================================================================
"--设置在光标距离窗口顶部或底部一定行数时，开始滚动屏幕内容的行为
set scrolloff=5

"--设置与系统剪贴板同步
set clipboard+=unnamed

"--启用或禁用光标所在行的高亮显示
set cursorline

"--在搜索时忽略大小写
set ignorecase

"--设置相对行号 和 当前行的绝对行号
set number relativenumber

set hlsearch
"Vim 会在您输入搜索模式的过程中逐步匹配并高亮显示匹配的文本。
set incsearch
set ignorecase
set smartcase

nnoremap &lt;leader&gt;l :nohlsearch&lt;CR&gt;

"--高亮复制的文本
set highlightedyank
set showmode

" ================================================================================================
" = No Leader Keymaps =====================================
" ================================================================================================


"--将 kk 映射为 &lt;Esc&gt;
inoremap JK &lt;ESC&gt;

"--快速到行头行尾
nmap H ^
nmap L $

"--取消撤销
nnoremap U &lt;C-r&gt;

"--默认dd删除不保存到剪贴板
nnoremap x "_x
nnoremap X "_X
nnoremap d "_d
nnoremap D "_D

"--函数定义跳转
nnoremap gd :action GotoDeclaration&lt;CR&gt;

map &lt;C-t&gt; :action ActivateTerminalToolWindow&lt;CR&gt;
"--禅模式
map &lt;C-l&gt; :action ToggleDistractionFreeMode&lt;CR&gt;
map &lt;F2&gt; :NERDTree&lt;CR&gt;

"--窗口分割和导航
map &lt;c-o&gt; &lt;Action&gt;(Back)
map &lt;c-i&gt; &lt;Action&gt;(Forward)
map sh &lt;Action&gt;(SplitVertically)
map sl &lt;Action&gt;(SplitVertically)
map sj &lt;Action&gt;(SplitHorizontally)
map sk &lt;Action&gt;(SplitHorizontally)

" ================================================================================================
" = Leader Keymaps =====================================
" ================================================================================================

"将&lt;leader&gt;设置为 空格 键
let mapleader=" "

"关闭当前标签页
nmap &lt;C-q&gt; :action CloseEditor&lt;CR&gt;

"快速 导航/查找 项目中的其他文件
nmap &lt;leader&gt;f &lt;action&gt;(GotoFile)
"在整个项目中查找指定的文本、关键字或正则表达式，包括代码文件、配置文件和其他文件等
nmap &lt;leader&gt;c &lt;action&gt;(FindInPath)
"打开"Find Action"（查找动作）对话框
nmap &lt;leader&gt;fc &lt;action&gt;(GotoAction)
"重新格式化代码，使其符合预定义的代码样式和规范 \| 优化导入语句，删除未使用的导入，并将导入语句按字母顺序进行排列
nmap &lt;leader&gt;fm &lt;action&gt;(ReformatCode) \| &lt;action&gt;(OptimizeImports)

"切换到上一个标签页
nmap &lt;leader&gt;[ :action PreviousTab&lt;CR&gt;

"========== i ==========
nnoremap &lt;leader&gt;i :action ImplementMethods&lt;CR&gt;

"切换到下一个标签页
nmap &lt;leader&gt;] :action NextTab&lt;CR&gt;

"显示当前打开文件的文件结构弹出窗口，其中包含文件中的类、方法、字段等结构
nmap &lt;F4&gt; &lt;action&gt;(FileStructurePopup)

"========= t ==========
nnoremap &lt;F3&gt; :action Terminal.OpenInTerminal&lt;CR&gt;

"========= z ==========

"展开所有代码折叠区域
nmap &lt;leader&gt;zo &lt;action&gt;(ExpandAllRegions)
"折叠所有代码折叠区域
nmap &lt;leader&gt;zc &lt;action&gt;(CollapseAllRegions)

inoremap &lt;C-a&gt; &lt;Home&gt;
inoremap &lt;C-e&gt; &lt;End&gt;
inoremap &lt;C-d&gt; &lt;Delete&gt;

nnoremap &lt;leader&gt;h &lt;C-W&gt;h
nnoremap &lt;leader&gt;l &lt;C-W&gt;l
nnoremap &lt;leader&gt;j &lt;C-W&gt;j
nnoremap &lt;leader&gt;k &lt;C-W&gt;k

nnoremap &lt;C-j&gt; 5gj
nnoremap &lt;C-k&gt; 5gk

nnoremap &lt;leader&gt;ci :action CommentByLineComment&lt;CR&gt;
vnoremap &lt;leader&gt;ci :action CommentByLineComment&lt;CR&gt;
</code></pre>
</blockquote>
<h3>Dart</h3>
<p>根据 <a href="https://copr.fedorainfracloud.org/coprs/albertop/dart">https://copr.fedorainfracloud.org/coprs/albertop/dart</a>：</p>
<pre><code>sudo dnf copr enable albertop/dart
sudo dnf install dart
</code></pre>
<h2>清理软件包</h2>
<p>为了方便，直接去软件商店手动卸载不需要的软件包。</p>
<pre><code>sudo dnf clean all
sudo dnf autoremove
</code></pre>
]]></content>
        <author>
            <name>Kinnari</name>
            <uri>https://kinnari-blog.vercel.app/</uri>
        </author>
        <published>2025-09-11T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[蒋炎岩操作系统 笔记3]]></title>
        <id>https://kinnari-blog.vercel.app/posts/jyyos/note-3/</id>
        <link href="https://kinnari-blog.vercel.app/posts/jyyos/note-3/"/>
        <updated>2025-08-07T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[C 标准库：在语言机制上的运行库，大部分使用 C 语言本身实现，少部分需要底层支持（内联汇编等） C 语言是高级的汇编语言：硬件级操作 C...]]></summary>
        <content type="html"><![CDATA[<h2>C 标准库和实现</h2>
<ul>
<li><strong>C 标准库</strong>：在语言机制上的运行库，大部分使用 C 语言本身实现，少部分需要底层支持（内联汇编等）</li>
<li>C 语言是高级的汇编语言：</li>
</ul>
<ol>
<li><strong>硬件级操作</strong>
C 语言支持指针、直接内存访问和位操作，能精准控制硬件资源（如寄存器、内存地址），这与汇编语言相似。</li>
<li><strong>高效性</strong>
C 代码经编译后生成的机器指令效率接近汇编，且可通过内联汇编进一步优化关键代码，适合系统级开发。</li>
<li><strong>结构化抽象</strong>
相比汇编的指令式编程，C 语言提供函数、循环、条件分支等结构化语法，显著提升了代码可读性和可维护性。</li>
<li><strong>跨平台与可移植</strong>
C 语言通过编译器实现硬件适配，避免了汇编语言对特定架构的强依赖，同时保留了对底层细节的控制权。</li>
</ol>
<p><a href="https://www.musl.libc.org/">musl.libc</a> 里面：先调用 <code>_start</code> 函数进行几个内联汇编的指令，然后运行 <code>_start_c</code>，在进行一些预备操作后，使用 <code>exit(main(…))</code>（所以 main 函数才需要返回一个值）</p>
<p><code>popen</code> <code>pclose</code>：pipe stream to or from a process。用在在一个程序中启动另一个程序，并读取返回的值</p>
<p>环境变量的传递：</p>
<ol>
<li><code>int main(argc, char *argv[], char *envp[]);</code></li>
<li>声称系统中有一个变量为 <code>environ</code>，然后尝试读取，如：</li>
</ol>
<pre><code>#include &lt;stdio.h&gt;

extern char **exviron;

extern void ******************************end;

int main() {
    for (char **env = environ; *env; env++) {
        printf("%s\n", *env);
    }
    end = NULL;
}
</code></pre>
<h2>动态内存管理</h2>
<p>虽然 <code>mmap</code> 可以向操作系统申请任意大的内存（即使超过物理内存上限也可以），但是操作系统<strong>不支持</strong>分配一小段内存，需要应用程序每次想操作系统多要一点内存，并自己在内存上实现一个数据结构</p>
<blockquote>
<p>脱离 workload 做优化就是耍流氓</p>
</blockquote>
<p>需要管理的对象：认为对象空间越大，生存周期也应该越长才合理；否则例如如果取到了一个很大的对象（分配了一个很大的空间），然后就初始化一遍就结束了，显然不太合理。结论是：<code>malloc()</code> 几乎只需要管理较小的对象就好了。</p>
<p>在 glibc 中，<code>malloc()</code> 对小块内存分配，使用 <code>brk</code> 扩展堆，而对大块分配使用 <code>mmap</code>；同时维护了多个空闲块链表，被释放的内存可能不立即归还系统，而是放入空闲链表等待复用。如下是 linux 中每个进程的虚拟地址空间布局</p>
<pre><code>+---------------------+
|    text segment     | 代码
+---------------------+
|    data segment     | 全局变量
+---------------------+
|     heap            | malloc/sbrk/brk 扩展的区域
|     ↑ grows up      |
+---------------------+
|     mmap 区域       | mmap 分配的大块内存、库等
+---------------------+
|     stack           | 局部变量、函数调用栈
|     ↓ grows down    |
+---------------------+
</code></pre>
<p>当然会引发一系列可能出现的问题，如内存泄漏、悬挂指针、越界访问、二次释放等</p>
<h2>可执行文件</h2>
<p>什么是可执行文件：一个操作系统中的对象（文件）、一个字节序列、一个描述了状态机初始状态的数据结构</p>
<p>ELF：<strong>executable</strong> and <strong>linkable</strong> format（linkable 指可以链接外部函数、变量等）</p>
<p>可执行文件需要包含：动态链接库、基本信息（版本、体系结构……）、内存布局、其他……这当然不是唯一的选择，可以有其他的实现</p>
<pre><code>+----------------------+
| ELF Header           | &lt;-- 文件起始处 (0x00)
+----------------------+
| Program Header Table | &lt;-- 可选（用于可执行文件 / 共享库）
+----------------------+
| Section Header Table | &lt;-- 可选（用于目标文件 / 静态链接）
+----------------------+
| Sections (段数据)    |
|   .text, .data, etc. |
+----------------------+
</code></pre>
<ul>
<li>ELF header：前 64 / 52 字节</li>
<li>Program Header Table：用于运行时加载。每个 program header 描述一个段，如 <code>.text</code> 段、<code>.data</code> 段等的内存映射关系</li>
<li>Sections &amp; Section Header Table：用于链接与调试的信息，主要包括 <code>.text</code>（代码）、<code>.data</code>（数据）、<code>.bss</code>（未初始化变量）、<code>.symtab</code>（符号表）、<code>.strtab</code>（字符串表）等</li>
<li>Section Contents：在 ELF 文件的后半部分，存储着段数据本身
<ul>
<li><code>.text</code>：汇编/机器码指令</li>
<li><code>.data</code>：已初始化数据</li>
<li><code>.rodata</code>：只读数据（如字符串字面量）</li>
<li><code>.bss</code>：未初始化数据（不实际存储，但会占地址空间）</li>
<li><code>.symtab</code>, <code>.strtab</code>：符号表和字符串表</li>
</ul>
</li>
</ul>
<p>显然 ELF 不是人类友好的</p>
<p><strong>core dump</strong> 是指操作系统在某个进程发生严重错误（通常是崩溃）时，将该进程当时的内存内容、寄存器状态、调用栈等信息保存到一个文件中的行为（<strong>瞬间快照</strong>）。包括：</p>
<ul>
<li>程序崩溃时的内存内容（堆、栈、数据段等）</li>
<li>寄存器的值</li>
<li>调用栈（backtrace）</li>
<li>程序计数器（PC）等信息</li>
<li>打开的文件描述符（有配置时）</li>
</ul>
<p>保存下来的 core 文件也是一个 ELF 文件，这样就可以用 gdb 等工具还原程序崩溃前的状态进行分析。Windows 的休眠恢复就用了这个原理。</p>
<p>在 ELF 之前，使用 <code>a.out</code> (assembler output) 格式，但功能太少了（不支持动态链接、调试信息、内存对齐……）</p>
<p><strong>重定位</strong>：在需要链接外部函数/变量时，将对应位置留空，之后在<strong>链接</strong>的时候补齐即可实现重定位的功能。链接的时候，先把同名 section (<code>.text</code> <code>.data</code> 等) 拼接，然后计算需要的地址等</p>
<p>加载 ELF 文件（<code>execve</code>）是内核实现的一部分</p>
<p><code>#!</code>：操作系统在运行可执行文件时，会先检查前两个字符是否是 <code>#!</code>，如果是，则通过修改 <code>execve</code> 的参数的方式运行对应的程序，如对 <code>a.py</code>：</p>
<pre><code>#!/usr/bin/env python3
print("Hello")
</code></pre>
<p>当使用 <code>chmod +x</code> 指令后，运行 <code>./a.py</code> 时，会自动将 <code>execve</code> 的参数修改为 <code>execve("/usr/bin/env", ["/usr/bin/env", "python3", "a.py"], …)</code></p>
<h2>动态链接和加载</h2>
<p>拆解应用程序，实现运行库和应用代码分离：应用之间的库共享，大型项目分解</p>
<p>用 <code>file</code> 查看文件性质，<code>ldd</code> 查看依赖的库</p>
<p>通过 <code>mmap</code> 将 ELF 搬运到内存中，也就包括动态链接库（<code>.so</code> <code>.dll</code>）</p>
<p>动态链接的可执行文件执行时，首先加载的不是文件本身或者 libc，而是链接器如 <code>ld-linux-x86-64.so.2</code>，在 program header 中的前几排可以看到这样一行：</p>
<pre><code>[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
</code></pre>
<p><strong>virtual memory</strong>：操作系统维护 memory mappings 的数据结构，采用延迟加载（非必要不分配内存）、写时复制 copy-on-write（<code>fork</code> 时，父子进程先只读共享全部地址空间，当 page fault 的时候，写者复制一份来写，减少内存消耗）。如下是写时复制的说明图：</p>
<pre><code>flowchart TD
    A["父进程 fork()"] --&gt; B["父子进程共享内存页面&lt;br/&gt;(页表指向相同物理页)&lt;br/&gt;页面标记为只读"]
    B --&gt; C[父进程或子进程尝试写内存]
    C --&gt; D{该页面为只读？}
    D -- 是 --&gt; E["触发页错误 (Page Fault)"]
    E --&gt; F[内核分配一块新的物理页]
    F --&gt; G[将旧页面内容复制到新页面中]
    G --&gt; H[将写入进程的页表指向新页面]
    H --&gt; I[设置新页面为可写]
    I --&gt; J[写入成功，COW 完成]
    D -- 否 --&gt; J

    style A fill:#f9f,stroke:#333,stroke-width:1px
    style C fill:#ff9,stroke:#333,stroke-width:1px
    style F fill:#bbf,stroke:#333,stroke-width:1px
    style G fill:#bbf,stroke:#333,stroke-width:1px
    style J fill:#9f9,stroke:#333,stroke-width:1px
</code></pre>
<pre><code>graph TD
    subgraph Parent Process
        P1[页表项A ➝] --&gt; PageA
        P2[页表项B ➝] --&gt; PageB
    end

    subgraph Child Process
        C1[页表项A' ➝] --&gt; PageA
        C2[页表项B' ➝] --&gt; PageB
    end

    subgraph Physical Memory
        PageA[物理页 A&lt;br/&gt;只读]
        PageB[物理页 B&lt;br/&gt;只读]
    end

    style PageA fill:#ffe,stroke:#c00,stroke-width:2px
    style PageB fill:#ffe,stroke:#c00,stroke-width:2px
    style P1 fill:#bbf
    style P2 fill:#bbf
    style C1 fill:#bfb
    style C2 fill:#bfb
</code></pre>
<p><strong>memory deduplication</strong>：操作系统在后台扫描内存，如果有两个重复的 read-only pages，就合并；发现 cold pages，就压缩/swap 到硬盘</p>
<p><strong>动态链接</strong>：编译时，动态链接库调用 = 查表；链接时，收集所有符号，“生成”符号信息和相关代码。实现流程：</p>
<ul>
<li><strong>编译时</strong>
<ul>
<li>编译器使用头文件（<code>.h</code>）了解库中有哪些函数。</li>
<li>编译生成目标文件（.o）时，符号没有被解析。</li>
<li>链接器使用 <code>.so</code> 文件的符号表生成可执行文件，但不会复制库代码，只记录“需要链接哪些库”。</li>
</ul>
</li>
<li><strong>运行时</strong>
<ul>
<li>程序启动时，<code>ld.so</code> 动态链接器会：
<ul>
<li>读取 ELF 可执行文件中的 <code>.dynamic</code> 和 <code>.interp</code> 段，识别需要加载哪些共享库。</li>
<li>加载这些共享库（<code>.so</code> 文件）到内存。</li>
<li>使用重定位（relocation）技术将未解析的符号与库中实际地址关联起来。</li>
<li>修改 GOT（Global Offset Table）和 PLT（Procedure Linkage Table）表，使函数调用转向库中的实现。</li>
</ul>
</li>
</ul>
</li>
</ul>
<p><strong>动态加载</strong>：程序运行过程中，有程序自己决定是否加载一个动态库并使用其中的函数，接口 <code>dlopen</code>, <code>dlsym</code>, <code>dlclose</code>，实现流程：</p>
<ol>
<li>程序中调用 <code>dlopen("libxxx.so", RTLD_LAZY)</code> 加载库到进程地址空间中。</li>
<li>使用 <code>dlsym(handle, "function_name")</code> 获取函数指针。</li>
<li>调用函数指针来使用库中的函数。</li>
<li>使用完后，调用 <code>dlclose(handle)</code> 卸载库（可选）。</li>
</ol>
<p>对比：</p>
<table>
<thead>
<tr>
<th>特性</th>
<th>动态链接</th>
<th>动态加载</th>
</tr>
</thead>
<tbody>
<tr>
<td>加载时机</td>
<td>程序启动时</td>
<td>程序运行时</td>
</tr>
<tr>
<td>加载方式</td>
<td>系统自动加载</td>
<td>程序主动调用 <code>dlopen</code> 等</td>
</tr>
<tr>
<td>使用方式</td>
<td>直接调用函数</td>
<td>先获取函数指针再调用</td>
</tr>
<tr>
<td>使用场景</td>
<td>正常依赖、系统库等</td>
<td>插件系统、可选功能、热更新等</td>
</tr>
<tr>
<td>是否需要链接时指定</td>
<td>需要 (<code>-lxxx</code>)</td>
<td>不需要，在代码中动态指定 <code>.so</code> 名称</td>
</tr>
</tbody>
</table>
<p>代码示例：</p>
<pre><code># hello.c
#include &lt;stdio.h&gt;
void hello() { printf("Hello, dynamic link!\n"); }

# main.c
void hello();  // 声明

int main() {
    hello();  // 链接时由动态链接器找到真正的 hello 实现
    return 0;
}

# 编译
gcc -fPIC -shared -o libhello.so hello.c
gcc -o main main.c -L. -lhello
export LD_LIBRARY_PATH=.
./main
</code></pre>
<pre><code>// main.c
#include &lt;stdio.h&gt;
#include &lt;dlfcn.h&gt;

int main() {
    void* handle = dlopen("./libhello.so", RTLD_LAZY);
    if (!handle) { perror("dlopen"); return 1; }

    void (*hello)() = dlsym(handle, "hello");
    if (!hello) { perror("dlsym"); return 1; }

    hello();

    dlclose(handle);
    return 0;
}
</code></pre>
<p><code>LD_PRELOAD</code> 机制：在程序运行前预加载指定的共享库，并用库中的函数<strong>覆盖</strong>原本程序或其它库中的实现。工作原理：</p>
<ol>
<li>程序启动 → <code>ld.so</code> 读取 ELF 文件头中的 <code>.interp</code> 段，找到动态链接器（如 <code>/lib64/ld-linux-x86-64.so.2</code>）。</li>
<li>动态链接器初始化时读取环境变量 <code>LD_PRELOAD</code>。</li>
<li>对于 <code>LD_PRELOAD</code> 中指定的 <code>.so</code> 文件：
<ol>
<li>加载进地址空间。</li>
<li>查找导出的符号。</li>
<li>这些符号插入到符号解析顺序的<strong>最前面</strong>。</li>
</ol>
</li>
<li>后续所有符号解析都会<strong>优先查找这些库中的实现</strong>。</li>
</ol>
<p>可以用于如下场景：</p>
<table>
<thead>
<tr>
<th>应用场景</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td>✅ <strong>系统调用拦截</strong></td>
<td>hook <code>open</code>, <code>read</code>, <code>write</code>, <code>connect</code> 等函数，实现审计、沙箱、调试</td>
</tr>
<tr>
<td>✅ <strong>内存调试工具</strong></td>
<td>替换 <code>malloc</code>/<code>free</code> 实现，记录调用栈，检测内存泄漏（如 Valgrind）</td>
</tr>
<tr>
<td>✅ <strong>性能分析</strong></td>
<td>记录每次函数调用时间，例如 <code>malloc</code>/<code>free</code> 的时间开销</td>
</tr>
<tr>
<td>✅ <strong>热修复 bug</strong></td>
<td>修复某些已部署程序的逻辑 bug，无需重编译（灰度更新、补丁）</td>
</tr>
<tr>
<td>✅ <strong>库替代/兼容层</strong></td>
<td>实现替代 libc、libGL、libpthread 等实现，用于模拟、兼容</td>
</tr>
</tbody>
</table>
<p>举个例子：可以写一个 <code>mymalloc.c</code> 来修改 <code>malloc</code> 的使用，从而在不修改原有程序的基础上查看 malloc 情况</p>
<pre><code>#define _GNU_SOURCE
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;dlfcn.h&gt;
#include &lt;unistd.h&gt;
#include &lt;string.h&gt;

void* malloc(size_t size) {
    static void* (*real_malloc)(size_t) = NULL;
    if (!real_malloc) {
        real_malloc = dlsym(RTLD_NEXT, "malloc");
    }

    void* p = real_malloc(size);

    // 用 write 打印，避免 printf 引发递归
    char buf[100];
    int len = snprintf(buf, sizeof(buf), "malloc(%zu) = %p\n", size, p);
    write(STDOUT_FILENO, buf, len);

    return p;
}
</code></pre>
<p>然后编译</p>
<pre><code>gcc -fPIC -shared -o libmymalloc.so mymalloc.c -ldl
</code></pre>
<p>运行 <code>ls</code> 命令：</p>
<pre><code>LD_PRELOAD=./libmymalloc.so ls
</code></pre>
<p>需要注意的是，避免在预加载的动态库中调用被修改了的函数，不然会导致递归调用和栈溢出，出现 <code>segment fault</code> 等错误。比方说上面的例子中使用的是 <code>write</code> 而不能是 <code>printf</code>，因为 <code>printf</code> 会调用 <code>malloc</code>，最终出现类似于 <code>[1] 82377 segmentation fault (core dumped) LD_PRELOAD=./libmymalloc.so ls</code> 的错误</p>
<h2>构建应用生态</h2>
<pre><code>[BIOS/UEFI]
     ↓
[Bootloader (GRUB)] → 加载 vmlinuz + initramfs
     ↓
[Kernel]
     ↓
[initramfs] → 执行 /init → 挂载真实根
     ↓
[switch_root]
     ↓
[init (systemd)] → 启动服务、网络、图形界面等
     ↓
[用户登录 / 图形界面]
</code></pre>
<p>如下展示 linux 启动过程：</p>
<ul>
<li>BIOS/UEFI → Bootloader 阶段</li>
</ul>
<pre><code>flowchart TD
    PowerOn[上电]
    Firmware[BIOS / UEFI]
    Bootloader["Bootloader (GRUB, systemd-boot)"]
    KernelAndInitramfs["加载内核 (vmlinuz) + initramfs"]
    JumpToKernel[跳转执行内核]

    PowerOn --&gt; Firmware
    Firmware --&gt; Bootloader
    Bootloader --&gt; KernelAndInitramfs
    KernelAndInitramfs --&gt; JumpToKernel
</code></pre>
<ul>
<li>内核初始化阶段（Kernel Stage）</li>
</ul>
<pre><code>flowchart TD
    KernelStart[开始执行内核]
    InitDrivers[初始化驱动和子系统]
    MountInitramfs["挂载 initramfs 为根 /"]
    ExecuteInit["执行 initramfs 中的 /init 脚本"]

    KernelStart --&gt; InitDrivers
    InitDrivers --&gt; MountInitramfs
    MountInitramfs --&gt; ExecuteInit
</code></pre>
<ul>
<li>initramfs 阶段（过渡文件系统）</li>
</ul>
<pre><code>flowchart TD
    InitScript["/init 脚本"]
    LoadModules[加载必要内核模块]
    DetectRoot[探测并挂载真实根文件系统]
    SwitchRoot["switch_root/pivot_root 切换根"]
    ExecuteInit2["执行真实根中的 init（PID 1）"]

    InitScript --&gt; LoadModules
    LoadModules --&gt; DetectRoot
    DetectRoot --&gt; SwitchRoot
    SwitchRoot --&gt; ExecuteInit2
</code></pre>
<ul>
<li>用户空间（User Space）</li>
</ul>
<pre><code>flowchart TD
    InitPID1["init (PID 1)"]
    MountVirtualFS["挂载 /proc, /sys, /dev"]
    StartUdev["启动 udev 管理设备"]
    StartServices[启动服务（网络、sshd、图形等）]
    ReachTarget["达到默认目标（multi-user.target, graphical.target）"]
    UserLogin[用户登录终端或图形界面]

    InitPID1 --&gt; MountVirtualFS
    MountVirtualFS --&gt; StartUdev
    StartUdev --&gt; StartServices
    StartServices --&gt; ReachTarget
    ReachTarget --&gt; UserLogin
</code></pre>
]]></content>
        <author>
            <name>Kinnari</name>
            <uri>https://kinnari-blog.vercel.app/</uri>
        </author>
        <published>2025-08-07T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[蒋炎岩操作系统 笔记2]]></title>
        <id>https://kinnari-blog.vercel.app/posts/jyyos/note-2/</id>
        <link href="https://kinnari-blog.vercel.app/posts/jyyos/note-2/"/>
        <updated>2025-08-05T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[将物理计算机抽象成虚拟计算机 进程：程序的运行时状态随时间的演进；除了程序状态，OS 还会保存一些额外的（只读）信息，比如 pid；程序希望...]]></summary>
        <content type="html"><![CDATA[<h2>进程管理</h2>
<p>将物理计算机抽象成虚拟计算机</p>
<p>进程：程序的运行时状态随时间的演进；除了程序状态，OS 还会保存一些额外的（只读）信息，比如 pid；程序希望知道这样的信息 $\to$ 系统调用 syscall，如 <code>getpid() getcwd() getgid() getuid() getegid() geteuid() getpriority()</code> 等等</p>
<p>查看进程</p>
<pre><code>ps aux | grep &lt;pid&gt;
</code></pre>
<p>进程编号在不断递增，然而进程号一定是有限的。现代 linux 内核支持 PID 命名空间，在容器或者虚拟环境中隔离 PID 空间</p>
<p>Windows 中</p>
<ul>
<li>创建状态机：<code>spawn(path, argv)</code></li>
<li>销毁状态机：<code>_exit()</code></li>
</ul>
<p>UNIX 中</p>
<ul>
<li>复制状态机：<code>fork()</code></li>
<li>复位状态机：<code>execve</code></li>
</ul>
<p>复制状态机时，原本的状态机（父进程）返回 pid，新的副本（子进程）返回 0</p>
<pre><code>int pid = fork();
if (pid &lt; 0) {
    // 异常处理
}

if (pid == 0) {
    // 子进程
} else {
    // 父进程
}
</code></pre>
<p>子进程有一部分状态是直接复制自父进程，还有一部分会被操作系统处理（如 pid、打开的文件等等）</p>
<p>用 <code>pstree</code> 来查看进程树</p>
<p>如果一个子进程的父进程被中止了，会有<strong>托孤</strong>机制，将子进程的状态返回给 1 号进程，或者准确来说 systemd 进程</p>
<p>fork 只能创建一样的进程，如果要创建新的进程，需要用 <code>execve</code></p>
<pre><code>int execve(const char *filename, char *const argv[], char *const envp[])
</code></pre>
<p><code>argv</code> 最后一位需要是 <code>NULL</code>，用于定位参数结束</p>
<p>一般来说</p>
<pre><code>pid = fork();

if (pid == 0) {
    execve(...);
} else {
    // 继续父进程
}
</code></pre>
<p>注意操作系统维护的那部分状态，在 <code>execve</code> 之后仍然是保持不变的；在 <code>execve</code> 之后的代码都不会被执行（因为已经让系统重置了状态机，原有的状态机不存在了）</p>
<p><code>execve</code> 是唯一能“执行程序”的系统调用</p>
<h2>进程的地址空间</h2>
<p>进程的初始状态：<code>execve</code> 之后的状态</p>
<p>ELF 文件中有一个 entry point address，是程序开始运行的地址</p>
<p>在实际中，系统调用不一定需要进入内核</p>
<p><code>/proc/&lt;pid&gt;/maps</code> 记录了所有内存映射状态；<code>/proc/&lt;pid&gt;/mem</code></p>
<p>一定有一个系统调用可以改变进程的地址空间</p>
<pre><code>// 映射
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);

// 修改映射权限
int mprotect(void *addr, size_t length, int prot);
</code></pre>
<ul>
<li><code>addr</code>：映射起始地址，通常为 <code>NULL</code>，操作系统自动选择合适的位置，是 page-aligned 的位置；即使设置了值，也不一定会在指定的位置进行分配，而是在附近寻找一个 page-aligned 的位置分配</li>
<li><code>length</code>：映射区域长度，单位为字节</li>
<li><code>prot</code>：内存保护方式（权限），<code>PROT_READ PROT_WRITE</code></li>
<li><code>flags</code>：控制映射行为，<code>MAP_SHARED</code> 共享映射，<code>MAP_PRIVATE</code> 私有映射</li>
<li><code>fd</code>：要映射的文件描述符</li>
<li><code>offset</code>：文件中的偏移量，表示从文件的哪个位置开始映射</li>
</ul>
<pre><code>void *mem = mmap(
    NULL, // let the kernel decides
    4096, // allocate 4kb
    PROT_READ | PROT_WRITE, // read and write
    MAP_PRIVATE | MAP_ANONYMOUS, // private and anonymous
    -1, // no file descriptor
    0 // no offset
)
</code></pre>
<p>将文件加载进地址空间：</p>
<pre><code>int fd = open(argv[0], O_RDONLY);
if (fd == -1) {
    perror("OPEN");
    return 1;
}

struct stat st;
if (fstat(fd, &amp;st) == -1) {
    perror("fstat");
    close(fd);
    return 1;
}

void *exe_map = mmap(
    NULL,
    st.st_size,
    PROT_READ,
    MAP_PRIVATE,
    fd,
    0
)
</code></pre>
<p>可以用 <code>munmap</code> 来回收</p>
<pre><code>munmap(mem, 4096);
munmap(exe_map, st.st_size)
close(fd);
</code></pre>
<p>先使用 <code>mmap</code> 分配内存，但此时操作系统不会真正的分配给程序，要等到程序运行到需要这个内存的时候，才抛出 <code>segment fault</code> 的异常，然后从物理内存中进行分配；因此可以实现瞬间分配内存</p>
<p><code>mmap</code> 和 <code>malloc</code> 的区别：</p>
<table>
<thead>
<tr>
<th>特性</th>
<th>malloc</th>
<th>mmap</th>
</tr>
</thead>
<tbody>
<tr>
<td>内存控制权</td>
<td>程序分配，系统释放</td>
<td>完全程序控制</td>
</tr>
<tr>
<td>默认初始化</td>
<td>一般初始化为可用内存块（实现相关）</td>
<td>可能为脏页，需自己初始化</td>
</tr>
<tr>
<td>分配位置</td>
<td>堆上</td>
<td>虚拟地址空间，通常在 heap 之外</td>
</tr>
<tr>
<td>适用场景</td>
<td>小块频繁分配</td>
<td>大块内存、文件映射、共享内存</td>
</tr>
</tbody>
</table>
<p>在使用 <code>mmap</code> 申请了一块内存之后，在这块内存上的所有操作都需要程序员手动管理（内存使用方式、生命周期、初始化、同步……）</p>
<h2>访问操作系统对象</h2>
<p>文件：有名字的数据对象</p>
<ul>
<li>字节流（终端，<code>/dev/urandom</code>）</li>
<li>字节序列（普通文件）</li>
</ul>
<p>文件描述符：</p>
<ul>
<li>指向操作系统对象的“指针”（但不是指针，而是 handle）</li>
<li>对象的访问都需要指针</li>
</ul>
<p><code>/proc/&lt;pid&gt;/fd</code></p>
<pre><code>ssize_t read(int fd, void *buf, size_t count);
</code></pre>
<p>对文件描述符而言，需要用 <code>open</code>、<code>close</code> 等进行打开、关闭，<code>dup</code> 用于备份</p>
<pre><code>int fd = open("sample.txt", O_RDWR | O_CREAT)
</code></pre>
<p><code>0 1 2</code> 分别是 stdin、stdout、stderr，新打开的文件从 3 开始分配</p>
<ul>
<li>文件描述符是进程文件描述符表的索引</li>
<li>关闭文件后，该描述符号可以被重新分配</li>
</ul>
<p>文件描述符实际上是指向了一个文件中的 offset，顺带了一个对象；当 <code>dup</code> 时，会<strong>共享</strong>这个 offset。这是因为每个文件描述符指向一个文件表项，<code>dup</code> 之后两个文件描述符指向了同一个文件表项</p>
<blockquote>
<p>[!NOTE]
操作系统打开一个文件时，涉及到三层数据结构：进程 $\to$ 文件描述符 $\to$ i-node（文件元数据）。文件描述符是进程私有的整数句柄，各指向一个文件表项（包括 offset 和打开标志 <code>O_RDONLY</code> 等）。而文件表项又指向 i-node，表示文件元信息，包括实际磁盘地址等</p>
</blockquote>
<p>在 Windows 系统中，文件描述符叫作 handle</p>
<p>任何“可读写”的东西都可以是文件，如真实设备 <code>/dev/sda</code> <code>/de/tty</code>，以及虚拟设备 <code>/dev/urandom</code> 等</p>
<p><strong>管道</strong>：一个特殊的“文件”（流），读口支持 <code>read</code>，写口支持 <code>write</code></p>
<pre><code>int pipe(int pipefd[2], int flags);
</code></pre>
<p>结合文件描述符的特点，可以用来实现进程之间的通信：先创建一个 pipe 对象，然后 <code>fork</code> 进程，如果想父进程写，子进程读，就在父进程中 <code>close</code> 读口，子进程中 <code>close</code> 写口</p>
<p>用 <code>man 2 pipe</code> 来查看创建管道时的约定</p>
<pre><code>#include &lt;fcntl.h&gt;
#include &lt;unistd.h&gt;

int main() {
    int pipefds[2];
    int result = pipe(pipefds);
    if (result == -1) {
        // 异常处理
    }

    // 开始 fork
    int pid = fork();
    if (pid == -1) {
        // 异常处理
    }

    if (pid == 0) {
        // 子进程
        close(pipefds[1]); // 关闭写口
    } else {
        // 父进程
        close(pipefds[0]); // 关闭读口
    }
}
</code></pre>
<p>上面的管道是匿名的，可以用 <code>mkfifo</code> 来显式创建一个管道</p>
<pre><code>#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;unistd.h&gt;
#include &lt;sys/types.h&gt;
#include &lt;sys/stat.h&gt;
#include &lt;sys/wait.h&gt;
#include &lt;fcntl.h&gt;
#include &lt;string.h&gt;

#define FIFO_PATH "myfifo"

int main() {
    if (mkfifo(FIFO_PATH, 0666) == -1) {
        perror("mkfifo");
        // 若已存在可以继续，不一定算错误
    }

    pid_t pid = fork(); // pid_t 就是 int

    if (pid &lt; 0) {
        perror("fork");
        exit(EXIT_FAILURE);
    }

    if (pid == 0) {
        // 子进程：读取数据
        int fd = open(FIFO_PATH, O_RDONLY);
        if (fd == -1) {
            perror("open (child)");
            exit(EXIT_FAILURE);
        }

        char buf[128];
        ssize_t n = read(fd, buf, sizeof(buf) - 1);
        if (n &gt; 0) {
            buf[n] = '\0';
            printf("Child (Reader) received: %s\n", buf);
        } else {
            printf("Child: nothing read\n");
        }

        close(fd);
        exit(0);
    } else {
        // 父进程：写入数据
        sleep(1); // 确保子进程先打开 FIFO

        int fd = open(FIFO_PATH, O_WRONLY);
        if (fd == -1) {
            perror("open (parent)");
            exit(EXIT_FAILURE);
        }

        const char* msg = "Hello from parent!";
        write(fd, msg, strlen(msg));
        printf("Parent (Writer) sent: %s\n", msg);
        close(fd);

        wait(NULL); // 等待子进程结束

        // 删除 FIFO 文件
        unlink(FIFO_PATH);
    }

    return 0;
}
</code></pre>
<p>Everything is a file 缺点：</p>
<ul>
<li>和各种 API 紧密耦合（如文件描述符的 offset）</li>
<li>对高速设备不好
<ul>
<li>额外的延迟和内存拷贝</li>
<li>单线程 I/O</li>
</ul>
</li>
</ul>
<h2>终端和 UNIX Shell</h2>
<p>现在的一般都是伪终端 <code>pty</code>，由一对主/从设备构成（在 <code>/dev/pts/</code> 下），用软件模拟了物理</p>
<ol>
<li>主设备：终端模拟器直接控制的端点
<ol>
<li><code>read()</code>：获取从设备的输出</li>
<li><code>write()</code>：发送键盘输入到从设备</li>
</ol>
</li>
<li>从设备：行为和物理终端完全一致（如 <code>/dev/tty</code>）
<ol>
<li>Shell 等程序通过该设备获取输入，输出显示内容</li>
</ol>
</li>
<li>主从设备通过内核双向管道连接</li>
</ol>
<p>对伪终端 <code>/dev/pts/5</code>，可以人为写入：</p>
<pre><code>echo Hello &gt; /dev/pts/5
</code></pre>
<p>创建：<code>openpty()</code> 通过 <code>/dev/ptmx</code>（pseudo-terminal master and slave） 申请一个新终端，返回两个文件描述符</p>
<p>有了主从设备之后，就可以通过对主设备进行修改实现许多功能</p>
<ul>
<li><code>Ctrl-C</code>：主设备接收到后，向从设备发送 <code>SIGINT</code></li>
<li>ssh 远程终端，一个机器允许多个终端连接</li>
</ul>
<p>进程组（process group）：最开始启动一个进程，默认是一个进程组，之后以 fork 的方式创建的进程就算在一个进程组中。在 UNIX 的每个会话（session）中，需要有一个 controlling terminal。</p>
<p><img src="_image/session.png" alt="" /></p>
<ul>
<li>
<p><code>Ctrl-z</code>：最小化按钮</p>
</li>
<li>
<p><code>fg %1</code>：最大化按钮</p>
</li>
<li>
<p><strong>会话组</strong>：每个进程继承父进程的 session ID (SID)，一个 session ID 管理一个控制终端，当这个终端退出时，所有的进程都被发送 SIGHUP 的信号（nohup 依赖的原理）</p>
</li>
<li>
<p><strong>进程组</strong>：每个进程继承父进程的 process group ID (PGID)，同一时刻只能有一个<strong>前台</strong>进程组。当操作系统收到 <code>Ctrl-c</code> 时，会向<strong>前台进程组使所有进程</strong>发送 SIGINT 信号</p>
</li>
</ul>
<p>联想之前的进程树的概念，即使某个子进程的父进程结束了（从而导致托孤），当 <code>ctrl-c</code> 时，这个子进程仍然会被杀掉，正是因为有 PGID 的存在</p>
<p>shell 语言：只有字符串，所有操作都是对字符串的处理（类似 C 语言中的预编译）</p>
<ul>
<li>预处理：<code>$()</code>，<code>&lt;()</code></li>
<li>重定向：<code>cmd &gt; file &lt; file 2&gt; /dev/null</code>，<code>&gt; file</code> 重定向标准输出，<code>&lt; file</code> 重定向标准输入，<code>2&gt; file</code> 重定向标准错误流</li>
<li>顺序结构：<code>cmd1; cmd2</code>，<code>cmd1 &amp;&amp; cmd2</code>，<code>cmd1 || cmd2</code>（短路求值）</li>
<li>管道：<code>cmd1 | cmd2</code></li>
</ul>
]]></content>
        <author>
            <name>Kinnari</name>
            <uri>https://kinnari-blog.vercel.app/</uri>
        </author>
        <published>2025-08-05T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[蒋炎岩操作系统 笔记1]]></title>
        <id>https://kinnari-blog.vercel.app/posts/jyyos/note-1/</id>
        <link href="https://kinnari-blog.vercel.app/posts/jyyos/note-1/"/>
        <updated>2025-07-31T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[为什么学操作系统：知道程序能做什么、为什么能做 什么是操作系统：一种程序，管理软硬件资源，为其他程序提供更好的服务 硬件：时钟驱动下的状态机...]]></summary>
        <content type="html"><![CDATA[<h2>导论</h2>
<p>为什么学操作系统：知道程序能做什么、为什么能做</p>
<p>什么是操作系统：一种程序，管理软硬件资源，为其他程序提供更好的服务</p>
<ul>
<li>硬件：时钟驱动下的状态机，一个时钟周期进行一次状态转移</li>
<li>软件：也是一个状态机，执行一条语句，相当于进行一次状态转移</li>
</ul>
<h2>应用视角的操作系统</h2>
<p>计算机：无情的执行指令的机器</p>
<p>程序的执行就是状态的变化</p>
<p>任何代码都是状态机；编译器实现了高级语言代码到机器代码两种状态机的翻译；对一个程序，想要将结果传递到程序外，都需要操作系统使用系统调用，可以用 <code>strace</code> 命令查看</p>
<p>各种软件：</p>
<ul>
<li>可见的：vim、bash、ffmpeg、ip、ssh……</li>
<li>不可见的 daemon：systemd、……</li>
</ul>
<h2>硬件视角的操作系统</h2>
<p>硬件视角不知道有操作系统的存在：下层不需要知道上层怎么用，只需要提供服务就行了</p>
<p>操作系统就是一个普通的程序，接管中断、I/O、……</p>
<p>可以看成状态机：</p>
<ul>
<li>状态：内存、寄存器的数值，外部世界的状态（存在但计算机系统不能直接访问）</li>
<li>初始状态：由系统设计者规定，reset 或者启动时转移到初始状态上来</li>
<li>状态迁移：改变 PC 取指令、响应中断、输入输出</li>
</ul>
<p>固件（firmware）：固定在系统中的代码，每次 CPU reset 或者 init 时运行所有程序前配置计算机系统，可以说是加载操作系统；正常状态是只读的，但是可以通过特殊方式打开写保护；BIOS 和 UEFI</p>
<h2>数学视角的操作系统</h2>
<p>程序是一种“数学严格”的对象</p>
<p>将操作系统视为一个管理了若干程序状态机的“更大”的 "main" 状态机；这个 main 状态机每一步执行，都相当于主动随机选择一个程序状态机，然后走一步；而系统调用相当于创建/退出/写入写出状态机</p>
<p>程序定义了一个状态机 $G(V,E)$，起点 $v_{0}$，以及“坏”的状态 $F\subseteq V$，那么程序正确等价于不存在从 $v_{0}$ 到 $v\in F$ 的路径</p>
]]></content>
        <author>
            <name>Kinnari</name>
            <uri>https://kinnari-blog.vercel.app/</uri>
        </author>
        <published>2025-07-31T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[当飞鸟划过天空]]></title>
        <id>https://kinnari-blog.vercel.app/posts/music/%E5%BD%93%E9%A3%9E%E9%B8%9F%E5%88%92%E8%BF%87%E5%A4%A9%E7%A9%BA/</id>
        <link href="https://kinnari-blog.vercel.app/posts/music/%E5%BD%93%E9%A3%9E%E9%B8%9F%E5%88%92%E8%BF%87%E5%A4%A9%E7%A9%BA/"/>
        <updated>2025-07-31T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[https://music.163.com/#/song?id=2707025561 演唱：Celine/VISION SOUND 作词...]]></summary>
        <content type="html"><![CDATA[<blockquote>
<p><a href="https://music.163.com/#/song?id=2707025561">https://music.163.com/#/song?id=2707025561</a>
演唱：Celine/VISION SOUND
作词：Xulai
作曲：jkinss
编曲：jkinss</p>
</blockquote>
<p>&lt;iframe src="//player.bilibili.com/player.html?isOutside=true&amp;aid=114572916687338&amp;bvid=BV1sRj1zcEhV&amp;cid=30159077586&amp;p=1&amp;autoplay=0" scrolling="no" border="0" frameborder="no" framespacing="0" allowfullscreen="true"&gt;&lt;/iframe&gt;</p>
<p>Birds please
鸟儿
send this song to the souls
请传送这首歌
who are weighed down by grief yet find the strength to believe
给背负痛楚却仍勇于相信的灵魂
Weave a dream with me
在潮汐的织锦上
on the tapestry of tides
编一段梦
Strum my strings
为无尽的时间之舞
for the endless dance of time
拨动琴弦</p>
<p>To fly is not to flee
飞翔并不是逃离
But to journey towards the dreams
而是朝向梦想的旅途进发
All the hopes will set us free
所有希望都将把我们
from doubts and fears deep within
从内心深处的迟疑和恐惧中释放</p>
<p>Let the sunrise gild our wings
让朝阳给我们的翅膀镀上金边
Let the twilight soothe our skin
让暮色轻抚我们的皮肤
In the sky we're forever wild and free
在天空中，我们永远舒畅自由</p>
<p>To fly is not to flee
飞翔并不是逃离
But to journey towards the dreams
而是朝向梦想的旅途进发
All the hopes will set us free
所有希望都将把我们
from doubts and fears deep within
从内心深处的迟疑和恐惧中释放
Let the sunrise gild our wings
让朝阳给我们的翅膀镀上金边
Let the twilight soothe our skin
让暮色轻抚我们的皮肤
In the sky we're forever wild and free
在天空中，我们永远舒畅自由</p>
]]></content>
        <author>
            <name>Kinnari</name>
            <uri>https://kinnari-blog.vercel.app/</uri>
        </author>
        <published>2025-07-31T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[如何拓展大模型上下文长度]]></title>
        <id>https://kinnari-blog.vercel.app/posts/extend-llm-context-length/</id>
        <link href="https://kinnari-blog.vercel.app/posts/extend-llm-context-length/"/>
        <updated>2025-07-28T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[最近需要将 Qwen2.5 Math 7B 的模型上下文从 4k 扩展到 16k，记录一下是怎么实现的。这里采用的是 NTK aware 的...]]></summary>
        <content type="html"><![CDATA[<p>最近需要将 Qwen2.5 Math 7B 的模型上下文从 4k 扩展到 16k，记录一下是怎么实现的。这里采用的是 NTK aware 的方法，原因是足够简单且可行。</p>
<h2>RoPE</h2>
<p>Qwen 系列模型使用了 RoPE 位置编码，所以首先回顾一下 RoPE 的方法：对于给定的 Query ($q$) 和 Key ($k$) 向量，它们在送入注意力计算之前，会根据其在序列中的绝对位置 $i$，将每两个维度视为复平面上的一个点，进行旋转。由于旋转的相对性，天然就有相对位置编码的特性。</p>
<p>对一个维度为 $d$ 的向量 $x=[x_0,x_1,\dots,x_{d-1}]$（在序列中的位置为 $m$），将第 $2i$ 和 $2i+1$ 位作为一个二维向量进行旋转：</p>
<p>$$
\begin{pmatrix} x'<em>{2i} \ x'</em>{2i+1} \end{pmatrix} = \begin{pmatrix} \cos(m\theta_i) &amp; -\sin(m\theta_i) \ \sin(m\theta_i) &amp; \cos(m\theta_i) \end{pmatrix} \begin{pmatrix} x_{2i} \ x_{2i+1} \end{pmatrix}
$$</p>
<p>其中 $\theta_{i}$ 是一个和位置 $i$ 相关的角频率，计算方式为：</p>
<p>$$
\theta_{i}=b^{-2i/d}
$$</p>
<p>$b$ 为基频，在 Qwen2.5 Math 7B 中为 $10000$。在实际代码中，旋转矩阵 $\mathbf{R}_{\mathbf{\Theta},m}^{d}$ 非常稀疏，需要使用如下方式进行计算以提高计算效率：</p>
<p>$$
\mathbf{R}^d_{\mathbb{\Theta},m} \mathbf{x} = \begin{pmatrix}
x_0 \
x_1 \
x_{2} \
x_{3} \
\cdots \
x_{d-2} \
x_{d-1}
\end{pmatrix} \otimes \begin{pmatrix}
\cos m \theta_0 \
\cos m\theta_{0} \
\cos m\theta_{1} \
\cos m\theta_{1} \
\cdots \
\cos m\theta_{\frac{d}{2}-1} \
\cos m\theta_{\frac{d}{2}-1}
\end{pmatrix} + \begin{pmatrix}
-x_{1} \
x_{0} \
-x_{3} \
x_{2} \
\cdots \
-x_{d-1} \
x_{d-2}
\end{pmatrix} \otimes \begin{pmatrix}
\sin  m \theta_0 \
\sin  m\theta_{0} \
\sin m\theta_{1} \
\sin m\theta_{1} \
\cdots \
\sin m\theta_{\frac{d}{2}-1} \
\sin m\theta_{\frac{d}{2}-1}
\end{pmatrix}
$$</p>
<p>在 LLaMA 中的实现 [^1]：</p>
<pre><code># 生成旋转矩阵
def precompute_freqs_cis(dim: int, seq_len: int, theta: float = 10000.0):
    # 计算词向量元素两两分组之后，每组元素对应的旋转角度\theta_i
    freqs = 1.0 / (theta ** (torch.arange(0, dim, 2)[: (dim // 2)].float() / dim))
    # 生成 token 序列索引 t = [0, 1,..., seq_len-1]
    t = torch.arange(seq_len, device=freqs.device)
    # freqs.shape = [seq_len, dim // 2]
    freqs = torch.outer(t, freqs).float()  # 计算m * \theta

    # 计算结果是个复数向量
    # 假设 freqs = [x, y]
    # 则 freqs_cis = [cos(x) + sin(x)i, cos(y) + sin(y)i]
    freqs_cis = torch.polar(torch.ones_like(freqs), freqs)
    return freqs_cis

# 旋转位置编码计算
def apply_rotary_emb(
    xq: torch.Tensor,
    xk: torch.Tensor,
    freqs_cis: torch.Tensor,
) -&gt; Tuple[torch.Tensor, torch.Tensor]:
    # xq.shape = [batch_size, seq_len, dim]
    # xq_.shape = [batch_size, seq_len, dim // 2, 2]
    xq_ = xq.float().reshape(*xq.shape[:-1], -1, 2)
    xk_ = xk.float().reshape(*xk.shape[:-1], -1, 2)

    # 转为复数域
    xq_ = torch.view_as_complex(xq_)
    xk_ = torch.view_as_complex(xk_)

    # 应用旋转操作，然后将结果转回实数域
    # xq_out.shape = [batch_size, seq_len, dim]
    xq_out = torch.view_as_real(xq_ * freqs_cis).flatten(2)
    xk_out = torch.view_as_real(xk_ * freqs_cis).flatten(2)
    return xq_out.type_as(xq), xk_out.type_as(xk)

class Attention(nn.Module):
    def __init__(self, args: ModelArgs):
        super().__init__()

        self.wq = Linear(...)
        self.wk = Linear(...)
        self.wv = Linear(...)

        self.freqs_cis = precompute_freqs_cis(dim, max_seq_len * 2)

    def forward(self, x: torch.Tensor):
        bsz, seqlen, _ = x.shape
        xq, xk, xv = self.wq(x), self.wk(x), self.wv(x)

        xq = xq.view(batch_size, seq_len, dim)
        xk = xk.view(batch_size, seq_len, dim)
        xv = xv.view(batch_size, seq_len, dim)

        # attention 操作之前，应用旋转位置编码
        xq, xk = apply_rotary_emb(xq, xk, freqs_cis=freqs_cis)

        # scores.shape = (bs, seqlen, seqlen)
        scores = torch.matmul(xq, xk.transpose(1, 2)) / math.sqrt(dim)
        scores = F.softmax(scores.float(), dim=-1)
        output = torch.matmul(scores, xv)  # (batch_size, seq_len, dim)
  # ......
</code></pre>
<p>对位置在 $m$ 处的 query $q_{m}$ 和位置在 $n$ 处的 key $k_{n}$，计算内积就变成了</p>
<p>$$
\begin{align}
(\mathbf{R}<em>{\mathbf{\Theta},m}^{d}q</em>{m})^{\top}(\mathbf{R}<em>{\mathbf{\Theta},m}^{d}k</em>{n}) &amp; = q_{m}^{\top}\left({\mathbf{R}<em>{\mathbf{\Theta},m}^{d}}^{\top}\mathbf{R}</em>{\mathbf{\Theta},n}^{d}\right) k_{n} \
&amp; = q_{m}^{\top}\mathbf{R}<em>{\mathbf{\Theta },m-n}^{d}k</em>{n}
\end{align}
$$</p>
<p>的确只和相对位置有关。</p>
<p>对 $\theta_{i}$，当 $i$ 较小时（$x$ 的低维部分），$\theta_{i}\approx 1$，是高频信息（即在 $m\theta_{i}$ 旋转速度更快），用于捕捉短距离注意力关系；而当 $i$ 较大时（$x$ 的高维部分），是低频信息，用于捕捉长距离注意力关系。总的来说就是<strong>低维高频、高维低频</strong>。至此，对于 RoPE 的理解就足够了，可以开始推导如何扩展上下文长度了。</p>
<h2>扩展上下文长度</h2>
<p>虽然好像 RoPE 编码的定义使其好像很容易进行上下文扩展（扩展上下文长度就相当于增大 $m$ 的取值范围），但是注意，由于基频是预先定义好的，所以所有维度的频率都是固定的，面对更长的长度，$m$ 的取值范围增大，$m\theta_{i}$ 的变化范围也会增大，多出来的范围就是模型并没有学习过的，对<strong>高频短距离信息</strong>尤为如此；从另一个视角来看，上下文长度的增加会带来更多的<strong>低频长距离信息</strong>，这是模型没有学习过的。因此必须做出一些必要的调整以适应增加的低频长距离信息。与此同时，需要注意的是，高频的短距离信息几乎不应该有变化，因此调整时也要注意到这些信息的保存。</p>
<p>由于低频、高频信息和 $\theta_{i}$ 直接相关，而 $\theta_{i}=b^{-2i/d}$ 中的 $d$ 是随模型固定的，$i$ 应该尽量避免变化，所以自然的想法是调整原有的基频 $b$。从上面说到的，上下文长度扩展的程度对低频信息的引入有很大的关联，因此引入一个变量 $s$ 为新、旧最大上下文长度的比值，并定义新的基频</p>
<p>$$
b' = b\cdot s^{k}
$$</p>
<p>这里 $k$ 是一个待定的常数。带入 $\theta_{i}$ 可得</p>
<p>$$
\theta'<em>{i} = (b')^{-2i/d} = b^{-2i/d}\cdot s^{-2ik/d} = \theta</em>{i}\cdot s^{-2ik/d}
$$</p>
<p>对高频信息，当 $i\to 0$ 时，$s^{-2ik/d}\to 1$，故 $\theta'<em>{i}\approx\theta</em>{i}$，满足之前讨论到的“高频的短距离信息几乎不应该有变化”。</p>
<p>而对低频信息，即当 $i\to \frac{d}{2}$ 时（注意 $i$ 的取值范围是 $[0, d / 2-1]$），由于扩展 $s$ 倍上下文，所以希望能够在低频部分进行 $s^{-1}$ 的线性插值（角频率缩小 $s$ 倍），即希望 $s^{-2ik/d}\approx s^{-1}$，得到</p>
<p>$$
k = \frac{d}{2i_{\text{max}}}=\frac{d}{d-2}
$$</p>
<p>那么也就得到了最终的基频修改公式：</p>
<p>$$
b'=b\cdot s^{\frac{d}{d-2}}
$$</p>
<p>在实际应用中，发现由于 $d$ 通常比较大，即使是取 $b'=b\cdot s$ 也能有不错的效果。并且原作者提到的，对 LLaMA 7B 的模型，你甚至不需要做任何的微调就能适应新的上下文长度！[^2]</p>
<h2>扩展 Qwen2.5 Math 7B 上下文长度</h2>
<p>回到正题，终于可以扩展上下文长度了！Qwen2.5 Math 7B 的默认参数配置 [^3] 为：</p>
<pre><code>{
  "architectures": [
    "Qwen2ForCausalLM"
  ],
  "attention_dropout": 0.0,
  "bos_token_id": 151643,
  "eos_token_id": 151643,
  "hidden_act": "silu",
  "hidden_size": 3584,
  "initializer_range": 0.02,
  "intermediate_size": 18944,
  "max_position_embeddings": 4096,
  "max_window_layers": 28,
  "model_type": "qwen2",
  "num_attention_heads": 28,
  "num_hidden_layers": 28,
  "num_key_value_heads": 4,
  "rms_norm_eps": 1e-06,
  "rope_theta": 10000,
  "sliding_window": 4096,
  "tie_word_embeddings": false,
  "torch_dtype": "bfloat16",
  "transformers_version": "4.44.0",
  "use_cache": true,
  "use_mrope": false,
  "use_sliding_window": false,
  "vocab_size": 152064
}
</code></pre>
<p>这里的 <code>rope_theta</code> 就是基频 $b$，由于使用了多头注意力机制，所以实际的向量维度为 <code>hidden_size / num_attention_heads</code>，即 $128$，新旧最大上下文长度之比 $s=16\text{k} / 4\text{k}=4$，那么可以算得</p>
<p>$$
b'=b\cdot s^{\frac{d}{d-2}} \approx 41810.5
$$</p>
<p>如果取 $s$ 的指数为 $1$，那么</p>
<p>$$
b'\approx b\cdot s=40000.0
$$</p>
<p>将 <code>max_position_embeddings</code> 和 <code>rope_theta</code> 的值分别修改为 $16384$ 和 $40000$ 即可。</p>
<h2>结束……了？</h2>
<p>当然没有！实际上，上面所推导的方法正是 NTK Aware 方法 [^2]，基于这个方法，还有一些其他的变体，例如 Dynamic NTK Interpolation[^4]、NTK-by-parts Interpolation[^5]、YaRN[^6] 等，可以阅读 <a href="https://zhuanlan.zhihu.com/p/15311461897">这篇博客</a> 来迅速了解这些方法。</p>
<p>另外，上面的推导仅仅只是对 NTK aware 算法的一个近似求解，具体的推导过程可以看下面的内容（对数学要求较高，可以跳过不看）。</p>
<h3>神经内切核</h3>
<p>首先我们需要了解 NTK（Neural Tangent Kernel，神经内切核）的理论 [^7]。对于一个由参数 $\mathbf{\theta}\in \mathbb{R}^{P}$ 参数化的神经网络 $f(\mathbf{x};\mathbf{\theta})$，其神经内切核 $\mathbf{\Theta}(\mathbf{x},\mathbf{x}')$ 是一个衡量输入 $\mathbf{x}$ 和 $\mathbf{x}'$ 之间关系的核函数，定义为</p>
<p>$$
\mathbf{\Theta}(\mathbf{x},\mathbf{x}')= \langle \nabla_{\theta}f(\mathbf{x};\theta), \nabla_{\theta}f(\mathbf{x}';\mathbf{\theta}) \rangle
$$</p>
<p>根据 Jacot 等 [^7] 的研究，可以知道在无限宽度和无穷小学习率的极限下，神经网络的训练动态可以用这个核函数来精确描述。模型在函数空间中的演化等价于一个<strong>核回归</strong>问题，其使用的核就是 NTK。因此，NTK 的性质（尤其是其谱特性）决定了模型的学习能力和泛化行为。</p>
<h3>NTK Aware 方法</h3>
<h4>RoPE 核函数</h4>
<p>将 RoPE 视为一个特征映射 $\phi:\mathbb{N}\to \mathbb{C}^{d/2}$，将一个位置索引 $m$ 映射到一个 $\frac{d}{2}$ 维的复数向量：</p>
<p>$$
\phi(m)=\frac{1}{\sqrt{ d/2 }}\begin{pmatrix}
e^{\mathrm{i}m\theta_{0}} \
e^{\mathrm{i}m\theta_{1}} \
\dots \
e^{\mathrm{i}m\theta_{d/2-1}}
\end{pmatrix}
$$</p>
<p>其中 $\theta_{i}=b^{-2i/d}$，$(d / 2)^{-1/2}$ 用于归一化。</p>
<p>然后将注意力机制中 RoPE 的贡献抽象为一个<strong>核函数</strong> $K(m,n)$，用于衡量位置 $m$ 和 $n$ 之间的相似性，有</p>
<p>$$
\begin{align}
K(m,n) &amp; = \mathrm{Re}(\langle \phi(m),\phi(n) \rangle) \
&amp; = \mathrm{Re}\left( \frac{2}{d}\sum_{k=0}^{d/2-1} e^{\mathrm{i}(m-n)\theta_{k}} \right) \
&amp; = \frac{2}{d} \sum_{i=0}^{d/2-1} \cos(m-n)\theta_{i}
\end{align}
$$</p>
<p>只和 $m-n$ 有关，为了简单，记核函数 $K(m,n)=K(\Delta m)=K(\Delta m; b)$，$f(x)=\cos(\Delta m\cdot x)$，则 $K(\Delta m)=\sum_{i=0}^{d/2-1}f(\theta_{i})$。另外也可以发现，任意两个位置之间的相似性和所有频率 ${ \theta_{i} }_{i=0,1,\dots,d/2-1}$ 相关，也就是说，</p>
<blockquote>
<p>[!NOTE]
模型内部对位置差异的感知，是由所有频率分量共同贡献的结果</p>
</blockquote>
<p>在进行上下文长度扩展前后，希望核函数的性质尽可能保持不变，也就是说</p>
<blockquote>
<p>[!NOTE]
在新的、扩展后的上下文中，任意两个位置 $m$ 和 $n$ 的核函数值 $K(\Delta m;b')$，与在原始上下文中、按比例缩放后的位置 $m / s, n / s$ 的核函数值 $K\left(\frac{m}{s}, \frac{n}{s};b \right)$ 尽可能保持一致：</p>
</blockquote>
<p>$$
K(m,n;b') \approx K\left( \frac{m}{s}, \frac{n}{s};b \right)
$$</p>
<p>即</p>
<p>$$
\sum_{i=0}^{d/2-1} \cos(\Delta m\cdot \theta'<em>{i}) \approx \sum</em>{i=0}^{d/2-1} \cos\left( \frac{\Delta m}{s} \theta_{i} \right)
$$</p>
<p>这里 $\theta_{i}'=b'^{-2i/d}$。那么问题就转化为如何计算或近似出上式的左右两边（实际上只需要左边就行了），当向量维度 $d$ 足够大时，求和可以转化为积分近似，即：</p>
<p>$$
\begin{align}
K(\Delta m;b) &amp; = \frac{2}{d} \sum_{i=0}^{d/2-1} f(\theta_{i}) \
&amp; = \frac{2}{d}\sum_{i=0}^{d/2-1} f(b^{-2i/d}) \
&amp; \approx \frac{2}{d} \int _{0}^{d/2} f(b^{-2i/d}) , \mathrm{d}i \
&amp; = \int <em>{0}^{1}f(b^{-x}) , dx =\int</em>{0}^{1}\cos(\Delta m\cdot b^{-x})\mathrm{d}x
\end{align}
$$</p>
<p>代入上式得到</p>
<p>$$
\int_0^1 \cos(\Delta m \cdot b'^{-x}) \mathrm{d}x \approx\int_0^1 \cos\left(\frac{\Delta m}{s} \cdot b^{-x}\right) \mathrm{d}x
$$</p>
<h4>谱密度函数</h4>
<p>然而，求解这个积分等式非常困难，需要通过其他途径进行分析。将核函数 $K(m,n)$ 生成的核矩阵 $[K]_{mn}$ 视为一个大型矩阵，从随机矩阵理论的视角，这个矩阵的谱特性（如谱密度、谱半径）与核函数的分析性质（如其傅里叶变换）紧密相关。保持谱特性的稳定是实现稳健上下文扩展的关键。我们来计算核函数 $K(m,n)$ 的谱密度 $\hat{K}(\omega;b)$：</p>
<p>$$
\begin{align}
\hat{K}(\omega;b) &amp; =\int_{-\infty}^{\infty} K(\Delta m;b)e^{-\mathrm{i}\omega\Delta m} , \mathrm{d}(\Delta m) \
&amp; = \int_{-\infty}^{\infty} \left( \int_{0}^{1} \cos(\Delta m\cdot b^{-x}), \mathrm{d}x \right) , \mathrm{d}(\Delta m) \
&amp; = \frac{1}{2} \int_{-\infty}^{\infty} \int_{0}^{1} (e^{\mathrm{i}\Delta m\cdot b^{-x}} + e^{-\mathrm{i}\Delta m\cdot b^{-x}}) e^{-\mathrm{i}\omega\Delta m} , \mathrm{d}x\mathrm{d}(\Delta m) \
&amp; = \frac{1}{2} \int <em>{0}^{1} \left[ \int</em>{-\infty}^{\infty} e^{\mathrm{i}\Delta m(b^{-x}-\omega)} , \mathrm{d}\Delta m+\int_{-\infty}^{\infty} e^{-\mathrm{i}\Delta m(b^{-x}+\omega)} , \mathrm{d}\Delta m   \right] , \mathrm{d}x
\end{align}
$$</p>
<p>代入狄拉克函数</p>
<p>$$
\delta(k)=\frac{1}{2\pi}\int_{-\infty}^{\infty} e^{\mathrm{i}kx} , \mathrm{d}x
$$</p>
<p>并由于 $\delta(x)=\delta(-x)$，得到</p>
<p>$$
\begin{align}
\hat{K}(\omega;b) &amp; = \frac{1}{2}\int _{0}^{1} [2\pi\delta(b^{-x}-\omega) + 2\pi\delta(-(b^{-x}+\omega))] , \mathrm{d}x \
&amp; = \pi \int _{0}^{1} \delta(\omega-b^{-x}) + \delta(\omega+b^{-x}) , \mathrm{d}x
\end{align}
$$</p>
<p>只考虑正频率 $\omega&gt;0$，由于 $b&gt;1$ 且 $0&lt;x&lt;1$，有 $b^{-x}&gt;0$，则 $\delta(\omega+b^{-x})=0$，上式简化为：</p>
<p>$$
\hat{K}(\omega;b)=\pi \int_{0}^{1} \delta(\omega-b^{-x}) \mathrm{d}x
$$</p>
<p>然后分析这个谱的支撑集。为了使谱密度 $\hat{K}(\omega;b) &gt; 0$，则必然存在 $x\in[0,1]$，使得 $\omega-b^{-x}=0$，解得 $x=-\ln \omega / \ln b$。为了让 $x\in[0,1]$，可以得到</p>
<p>$$
\frac{1}{b} \leq \omega \leq 1
$$</p>
<p>那么就得到：</p>
<blockquote>
<p>[!IMPORTANT]
理想化的 RoPE 核（在<strong>无限长序列</strong>上）其谱密度函数的支撑集为 $\left[\frac{1}{b}, 1 \right]$</p>
</blockquote>
<p>接着我们使用狄拉克函数的性质：</p>
<p>$$
\int g(x)\delta(f(x)) \mathrm{d}x = \sum_{i} \frac{g(x_{i})}{\lvert f'(x_{i}) \rvert }
$$</p>
<p>其中 $x_{i}$ 为 $f(x)=0$ 的根。代入后可以得到谱密度：</p>
<p>$$
\hat{K}(\omega;b)=\frac{\pi}{\omega \ln b}\text{, for } \omega\in\left[ \frac{1}{b},1 \right]
$$</p>
<p>它在低频端点 $\omega=\frac{1}{b}$ 处取最大值，在高频端点 $\omega=1$ 处取最小值。至此，谱密度函数的性质已经分析完毕。</p>
<h4>非理想情况</h4>
<p>然而以上是<strong>理想情况</strong>——因为我们不知不觉中，在计算谱密度的时候，假定了傅里叶变换的存在性，即 $\Delta m$ 可以取得 $\pm \infty$，也即序列长度可以达到 $+\infty$。这显然是不可能的。我们不得不回到现实情况中来，也即序列长度最多只能到达 $L$。此时核函数 $K(m,n)$ 的谱矩阵是一个 $L\times L$ 的矩阵，更准确的说，是一个托普利茨矩阵（常对角矩阵）[^8]，记为 $T_{L}(f)$：</p>
<blockquote>
<p>[!CAUTION]
除此之外，还有一个让这种理想情况不可能达到的原因，想想是什么？（在后面会有揭晓）</p>
</blockquote>
<p>$$
T_{L}(f) = \begin{pmatrix}
K(0) &amp; K(-1) &amp; K(-2) &amp; \cdots &amp; K(1-L) \
K(1) &amp; K(0) &amp; K(0) &amp; \cdots &amp; K(2-L) \
\vdots &amp; \vdots &amp; \vdots &amp; \ddots &amp; \vdots \
K(L-1) &amp; K(L-2) &amp; K(L-3) &amp; \cdots  &amp; K(0)
\end{pmatrix}
$$</p>
<p>这个矩阵的行为完全由生成函数 $f$，即上一步计算得到的谱密度函数 $\hat{K}(\omega;b)$ 决定 [^9]。</p>
<p>接下来，我们需要使用一个研究大型托普利茨矩阵行列式渐近行为的强大的引理：强斯格极限定理 (Strong Szegő Limit Theorem) [^10]，如下</p>
<blockquote>
<p>[!NOTE]
若生成函数 $f(\theta)$ 满足某些条件，例如 $f&gt;0$ 且足够光滑，则：</p>
<p>$$
\lim_{ L \to \infty } \frac{\det(T_{L}(f))}{G(f)^{L}} = E(f)
$$</p>
<p>其中：</p>
<ul>
<li>$G(f)$ 是 $f$ 的几何平均数 (Geometric Mean)，代表了行列式的体行为 (bulk behavior)。对于充分大的 $L$，有 $\det(T_{L}(f))\approx G(f)^{L}$。</li>
</ul>
<p>$$
G(f)=\exp\left( \frac{1}{2\pi}\int _{0}^{2\pi}\ln f(e^{i\theta}) , \mathrm{d}\theta  \right)
$$</p>
<ul>
<li>$E(f)$ 是一个和 $f$ 的光滑性相关的边界项，依赖于 $\ln f$ 的傅里叶系数</li>
</ul>
<p>$$
E(f)=\exp\left( \sum_{k=1}^{\infty} k\left\lvert\widehat{\ln f}_{k} \right\rvert^{2} \right)
$$</p>
<p>其中 $\widehat{\ln f}_{k}$ 是 $\ln f$ 的第 $k$ 个傅里叶系数。</p>
</blockquote>
<p>将上述引理应用于<strong>解释扩展上下文长度的稳定性质</strong>：回顾我们求出的谱密度函数：</p>
<p>$$
f(\omega)=\hat{K}(\omega;b) = \begin{cases}
\frac{\pi}{\omega \ln b} &amp; \frac{1}{b} \leq \omega \leq 1 \
\
0  &amp; \text{otherwise}
\end{cases}
$$</p>
<p>明显，在两侧端点 $\frac{1}{b}$ 和 $1$ 处，$f$ 均出现了跳变，结合强斯格极限定理，可以得到如下结论。</p>
<blockquote>
<p>[!IMPORTANT]
由于谱密度函数 $f$ 天然具有跳变不连续的性质，在简单的拓展上下文长度 $L$ 时，模型的离散频率分辨率 $\sim \frac{1}{L}$ 和谱密度函数 $f$ 两侧端点位置之间的关系发生了改变，系统进入了一个对这种不连续性极其敏感的区域。从而使矩阵 $T_L(f)$ 出现了离群特征值，同时 $L\to \infty$ 时 $E(f)$ 没有良好的收敛性质，从而导致了简单扩展上下文长度 $L$ 的崩溃。</p>
</blockquote>
<table>
<thead>
<tr>
<th>数学量</th>
<th>对应概念</th>
</tr>
</thead>
<tbody>
<tr>
<td>$\det(T_L(f))$</td>
<td>$T_L(f)$ 的（离群）特征值</td>
</tr>
<tr>
<td>E(f)</td>
<td>$f$ 的（不）连续性</td>
</tr>
</tbody>
</table>
<h4>离群特征值的出现原因</h4>
<p>可以看到，在上面的结论中，我们使用了一些描述性的语言，以便于当前能顺利理解。接下来，我们顺理成章的需要研究：</p>
<blockquote>
<p>这种不连续性是如何导致托普利茨矩阵 $T_{L}(f)$ 的离群特征值的出现，以及这一现象何时发生？
（但是这太困难了，已经超出了我的知识范围，只好请教 gemini 大人了😭，贴在下面等有缘人来确认正确性）</p>
</blockquote>
<p>首先让我们描述<strong>离群特征值是什么</strong>。根据 Fisher–Hartwig 猜想 [^11]（已被证明）以及 Harold Widom[^12] 的后续工作，可知对于一个具有跳变不连续的生成函数 $f(\omega)$，其对应的托普利茨矩阵 $T_{L}(f)$ 的谱在 $L\to \infty$ 时呈现如下结构：</p>
<ul>
<li><strong>连续谱 (Continuous Spectrum)</strong>：绝大多数（几乎所有）的特征值会密集地分布在由 $f(\omega)$ 的值域构成的区间内。这个区间我们称之为<strong>体谱 (bulk spectrum)</strong>。</li>
<li><strong>离散谱 (Discrete Spectrum)</strong>：在生成函数 $f(\omega)$ 的不连续点处，可能会有<strong>一个或多个特征值脱离体谱</strong>，成为孤立的离群点。</li>
</ul>
<p>不稳定性就等价于这些离群特征值的出现和它们的病态行为。</p>
<p>接下来，是最后的推导（虽然并不详细）。</p>
<p>Harold Widom[^12] 对特定生成函数，即可以表示为一个光滑函数和一个区间指示函数的乘积的函数，证明了<strong>积分方程定理</strong>（Integral Equation Theorem for Outliers）：当 $L\to \infty$ 时，大多数特征值位于所谓的体谱区间内（由函数值域决定），而离群特征值的渐近行为，可以被一个定义在有限区间上的<strong>积分算子</strong> $\mathcal{K}<em>L$ 的特征值精确描述。该积分算子的核一般与 sinc 核形式（如 $\sin(c(x-y)) / (x-y)$）相关。这意味着可以将一个复杂的、$L\times L$ 离散矩阵的谱问题，转化成一个更易于分析的连续积分算子 $\mathcal{K}</em>{L}$ 的问题。准确来说，$T_{L}(f)$ 的行列式的渐近行为被 $\mathcal{K}<em>{L}$ 的 Fredholm 行列式 [^13] $\det(I-\mathcal{K}</em>{L})$ 描述。</p>
<p>通过对这个等效积分算子 $\mathcal{K}_{L}$ 的研究，数学家们发现它的谱（特别是其最大特征值）存在<strong>相变 (Phase Transition)</strong>[^15] 现象。相变的发生与否，由一个关键的无量纲参数控制，我们称之为<strong>序参量</strong>，记为 $\beta$。</p>
<p>在进行接下来的推导之前，首先需要对原始 RoPE 核进行适当的“修正”：回归原始 RoPE 核：</p>
<p>$$
K(\Delta m;b)=\sum_{i=0}^{d/2-1} \cos(\Delta m\cdot b^{-2i/d})
$$</p>
<p>对 $i=0$ 这一项，即 $\cos(\Delta m)$ 这一项，因为它并非平方可积，所以不满足连续谱分析的前提，应该排除这一项，即稳定部分由 $i\in { 1,2,\dots,d/2-1 }$ 这些项构成。这样做改变了生成函数的精确形状和支撑集，但可以验证，其定性行为（如边界不连续性）依然存在。那么有效维度就只有 $d_{\text{eff}}=d-2$ 个了（排除了第 $0,1$ 两个维度）。</p>
<p>进行了这样的修正之后，$x=2k/d$ 的范围就缩小为了 $[2 / d, (d-2) / d]$，对应的频率 $\omega=b^{-x}$ 的范围变为 $[b^{-(d-2)/d}, b^{-2/d}]$，也就是新的支撑集。</p>
<p>通过分析与算子 $\mathcal{K}<em>{L}$ 相关的潘勒韦方程 [^14] 的解的渐近行为，并对支撑集下边界 $b^{-(d-2)/d}$ 进行分析，得到与这个边界相关的<strong>系统特征长度</strong> $\lambda</em>{\text{char}}$，和基频 $b$、有效维数 $d_{\text{eff}}$ 的关系为：</p>
<p>$$
\lambda_{\text{char}} \propto b^{\frac{d-2}{d}}
$$</p>
<p>这个特征长度可以被理解为系统内部结构能够保持相干的最大距离。定义上面提到的序参量 $\beta$ 为系统外在尺寸 $L$ 和这个内在特征长度 $\lambda_{\text{char}}$ 的比值：</p>
<p>$$
\beta=\frac{L}{\lambda_{\text{char}}} \propto L\cdot b^{-\frac{d-2}{d}}
$$</p>
<p>Harold Widom 和他的合作者一起证明了：离群特征值的出现，精确地发生在序参量 $\beta$ 跨越一个特定的临界值 $\beta_{c}$​ 的时候，即，当 $\beta &lt;\beta_{c}$​ 时，系统处于稳定状态，没有离群特征值；当 $\beta&gt;\beta_{c}$​ 时，系统失稳，离群特征值从体谱中分裂出来。因此，系统保持在临界稳定状态的条件是：</p>
<p>$$
\beta=\beta_{c}=\text{constant}
$$</p>
<p>那么为了能够稳定的扩展上下文，就需要有</p>
<p>$$
L\cdot b^{-\frac{d-2}{d}} =C
$$</p>
<p>$C$ 是一个常数。重新整理得到：</p>
<p>$$
b \propto L^{\frac{d}{d-2}}
$$</p>
<p>将新旧基频 $b'$ 和 $b$ 代入 $L'=s\cdot L$，得到</p>
<p>$$
b'\propto(s\cdot L)^{\frac{d}{d-2}} = s^{\frac{d}{d-2}}\cdot L^{\frac{d}{d-2}}
$$</p>
<p>那么就有</p>
<p>$$
b'=s^{\frac{d}{d-2}}\cdot b
$$</p>
<h4>总结</h4>
<p><s>至此，艺术已成。</s></p>
<p>至此，我们从理论上证明了：</p>
<blockquote>
<p>[!NOTE]
当扩展上下文长度 $L$ 时，基频 $b$ 需要满足 $b'=b\cdot s^{d/(d-2)}$ 的变化方式，才能保证模型不会崩溃。</p>
</blockquote>
<p>回顾我们在非理想情况一节中使用的描述性语言：</p>
<blockquote>
<p>由于谱密度函数 $f$ 天然具有跳变不连续的性质，在简单的拓展上下文长度 $L$ 时，模型的离散频率分辨率 $\sim \frac{1}{L}$ 和谱密度函数 $f$ 两侧端点位置之间的关系发生了改变，系统进入了一个对这种不连续性极其敏感的区域。从而使矩阵 $T_L(f)$ 出现了离群特征值，同时 $L\to \infty$ 时 $E(f)$ 没有良好的收敛性质，从而导致了简单扩展上下文长度 $L$ 的崩溃。</p>
</blockquote>
<p>现在可以理解：</p>
<ul>
<li><strong>系统进入了一个对这种不连续性极其敏感的区域</strong>：就是指随着 $L$ 的增大，$\beta &gt; \beta_c$，从而导致离群特征值的产生，也正是相变现象。</li>
<li><strong>模型的离散频率分辨率 $\sim \frac{1}{L}$ 和谱密度函数 $f$ 两侧端点位置之间的关系发生了改变</strong>：
<ul>
<li>对长度 $L$ 有限的离散序列，频率空间就是由离散傅里叶变换 (DFT) 的频率点构成，即 $\Omega_L = { \omega_k = \frac{2\pi k}{L} | k=0,1,2,\dots, L-1 }$，格点分辨率 $\Delta \omega = \frac{2\pi}{L}\sim \frac{1}{L}$</li>
<li>随着 $L$ 的增大，如果 $f$ 满足良好的性质，那么 $T_L(f_b)$ 的特征值会越来越精确地填充到体谱中；然而 $f$ 存在跳跃不连续点，所以这种情况不可能发生，从而导致离群特征值出现的必然性。说“关系”发生改变，也正是说离散特征值不能精确填充到体谱中去，逐渐变成了离群特征值。</li>
</ul>
</li>
</ul>
<p>总结一下我们的证明结论：</p>
<blockquote>
<p>[!NOTE]
对于一个由固定的谱密度函数 $f_b$（其支撑集 $I_b$ 的边界存在跳变不连续）生成的托普利茨算子/矩阵序列 ${ T_L(f_b) }<em>{L\in \mathbb{Z}^+}$，存在一个由上下文长度 $L$ 和系统参数 $(b ,d)$ 共同决定的无量纲序参量 $\beta \propto L \cdot b^{-(d-2)/d}$。当 $L$ 增大并使得 $\beta$ 超过一个临界阈值 $\beta_c$ 时，系统发生相变：$T_L(f_b)$ 的谱结构发生定性改变，一个或多个离群特征值会从体谱 $I_b$ 中分裂出来。这个相变点 $L</em>{\text{crit}} \propto b^{(d-2)/d}$ 定义了在不修改基数 $b$ 的情况下，上下文长度能够保持稳定的上限。因此，当扩展上下文长度 $L$ 时，基频 $b$ 需要满足 $b'=b\cdot s^{d/(d-2)}$ 的变化方式，才能保证模型不会崩溃。</p>
</blockquote>
<p>[^1]: 代码注释来自 <a href="https://zhuanlan.zhihu.com/p/647109286">https://zhuanlan.zhihu.com/p/647109286</a>
[^2]: <a href="https://www.reddit.com/r/LocalLLaMA/comments/14lz7j5/ntkaware_scaled_rope_allows_llama_models_to_have/">https://www.reddit.com/r/LocalLLaMA/comments/14lz7j5/ntkaware_scaled_rope_allows_llama_models_to_have/</a>
[^3]: <a href="https://huggingface.co/Qwen/Qwen2.5-Math-7B/blob/main/config.json">https://huggingface.co/Qwen/Qwen2.5-Math-7B/blob/main/config.json</a>
[^4]: <a href="https://www.reddit.com/r/LocalLLaMA/comments/14mrgpr/dynamically_scaled_rope_further_increases/">https://www.reddit.com/r/LocalLLaMA/comments/14mrgpr/dynamically_scaled_rope_further_increases/</a>
[^5]: <a href="https://github.com/jquesnelle/yarn/pull/1">https://github.com/jquesnelle/yarn/pull/1</a>
[^6]: <a href="https://arxiv.org/abs/2309.00071">https://arxiv.org/abs/2309.00071</a>
[^7]: <a href="https://arxiv.org/abs/1806.07572">https://arxiv.org/abs/1806.07572</a>
[^8]: <a href="https://zh.wikipedia.org/zh-hans/%E5%B8%B8%E5%B0%8D%E8%A7%92%E7%9F%A9%E9%99%A3">https://zh.wikipedia.org/zh-hans/常對角矩陣</a>
[^9]: 这里对 $f$ 和 $\theta$ 的使用有点泛滥，请不要将此处的 $f$ 和 $\theta$ 和上文中的 $f$ 相混淆
[^10]: <a href="https://en.wikipedia.org/wiki/Szeg%C5%91_limit_theorems">https://en.wikipedia.org/wiki/Szegő_limit_theorems</a>
[^11]: <a href="https://www.sciencedirect.com/science/article/pii/0024379594901872">https://www.sciencedirect.com/science/article/pii/0024379594901872</a>
[^12]: <a href="https://en.wikipedia.org/wiki/Harold_Widom">https://en.wikipedia.org/wiki/Harold_Widom</a>
[^13]: <a href="https://en.wikipedia.org/wiki/Fredholm_determinant">https://en.wikipedia.org/wiki/Fredholm_determinant</a>
[^14]: <a href="https://en.wikipedia.org/wiki/Painlev%C3%A9_transcendents">https://en.wikipedia.org/wiki/Painlevé_transcendents</a>
[^15]: <a href="https://en.wikipedia.org/wiki/Phase_transition">https://en.wikipedia.org/wiki/Phase_transition</a></p>
]]></content>
        <author>
            <name>Kinnari</name>
            <uri>https://kinnari-blog.vercel.app/</uri>
        </author>
        <published>2025-07-28T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[推荐系统——排序]]></title>
        <id>https://kinnari-blog.vercel.app/posts/recsys/3-ranking/</id>
        <link href="https://kinnari-blog.vercel.app/posts/recsys/3-ranking/"/>
        <updated>2025-07-20T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[[!NOTE] 这是 b 站王树森推荐系统课程的笔记，记录到了重排，之后的物品冷启动和提分部分我没有学，所以也没有笔记。索引如下：推荐系统...]]></summary>
        <content type="html"><![CDATA[<blockquote>
<p>[!NOTE]
这是 b 站王树森推荐系统课程的笔记，记录到了重排，之后的物品冷启动和提分部分我没有学，所以也没有笔记。索引如下：</p>
<ul>
<li><a href="/posts/recsys/1-intro">推荐系统——概述</a></li>
<li><a href="/posts/recsys/2-retrieval">推荐系统——召回</a></li>
<li><a href="/posts/recsys/3-ranking">推荐系统——排序</a></li>
<li><a href="/posts/recsys/4-feature-crossing">推荐系统——特征交叉</a></li>
<li><a href="/posts/recsys/5-behavior-sequence">推荐系统——行为序列</a></li>
<li><a href="/posts/recsys/6-re-ranking">推荐系统——重排</a></li>
</ul>
</blockquote>
<hr />
<blockquote>
<p><a href="https://www.bilibili.com/video/BV19t4y1p7UM">https://www.bilibili.com/video/BV19t4y1p7UM</a></p>
</blockquote>
<p>排序主要依据：用户——物品交互，点击率、点赞率、收藏率、转发率……将这些分数进行融合</p>
<h2>预估分数</h2>
<h3>多目标模型</h3>
<p><img src="_img/3-multi-target-model.png" alt="" /></p>
<p>使用神经网络预估多个分数（0 - 1 之间），通过这些分数进行排序。训练时需要让这些预估值尽可能接近真实值，可以用交叉熵。</p>
<p>训练中会遇到类别不平衡的问题，即正样本少但负样本多。解决方法是</p>
<ul>
<li>使用<strong>负样本降采样</strong>，抛弃一部分负样本，让正负样本数量平衡。</li>
<li><strong>预估值校准</strong>，因为降采样后，（期望）预估点击率 $E_{\text{pred}}=n_{+} / (n_{+}+\alpha \cdot n_{-})$ 会高于（期望）真实点击率 $E_{\text{true}} = n_{+} / (n_{+}+n_{-})$，校准公式为：$E_{\text{true}}= \alpha \cdot E_{\text{pred}} / ((1-E_{\text{pred}})+\alpha \cdot E_{\text{pred}})$</li>
</ul>
<h3>Multi-gate MoE (MMoE)</h3>
<p>很典型的 MoE 结构</p>
<p><img src="_img/3-MMoE-1.png" alt="" /></p>
<p><img src="_img/3-MMoE-2.png" alt="" /></p>
<p>实际使用时，存在<strong>极化问题</strong>，即权重过于集中在某一个 expert 上。可以在训练时对 softmax 的输出使用 dropout</p>
<h2>融合预估分数</h2>
<p>简单加权和：</p>
<p>$$
p_{\text{click}} + w_{1}\cdot p_{\text{like}} + w_{2}\cdot p_{\text{collect}} + \cdots
$$</p>
<p>点击率乘其他项的加权和：</p>
<p>$$
p_{\text{click}}\cdot(1+w_{1}\cdot p_{\text{like}} + w_{2}\cdot p_{\text{collect}} + \cdots)
$$</p>
<p>等等</p>
<h2>视频播放建模</h2>
<p>除了一般物品的特征，还有播放时长、完播率等特征。</p>
<h3>播放时长</h3>
<p>对一个用户，训练时，实际播放时长记为 $t$，神经网络输出为 $z$，不是直接期望 $t$ 和 $z$ 相近，而是进行一个变换</p>
<p>$$
p = \text{sigmoid}(z) = \frac{\exp(z)}{1+\exp(z)}, y=\frac{t}{1+t}
$$</p>
<p>再对 $p$ 和 $y$ 做交叉熵损失：</p>
<p>$$
-\left( y\log p+(1-y)\log(1-p) \right)
$$</p>
<p>去掉分母 $1+t$ 也是可以的。训练好之后将 $\exp(z)$ 用于融合预估分数</p>
<h3>视频完播率</h3>
<p>有多种建模方法，如：</p>
<ol>
<li>视频长度为 $10$，实际播放 $4$，则记为 $\frac{4}{10}=0.4$</li>
<li>视频长度为 $10$，只要超过 $8$，就为正样本，然后计算正样本占比</li>
</ol>
<p>不能将预估的完播率用于融分，因为不利于长视频，需要进行调整。</p>
<h2>排序模型的特征</h2>
<ul>
<li><strong>用户画像</strong>：ID，性别、年龄，账号信息，感兴趣的东西等等</li>
<li><strong>物品画像</strong>：ID，发布时间，GeoHash，类目、关键词，字数，信息量……</li>
<li><strong>用户统计特征</strong>：近 30 天的活动（曝光、点击、点赞、收藏），物品种类分桶统计</li>
<li><strong>物品统计特征</strong>：近 30 天（曝光、点击、点赞、收藏）、用户性别/年龄分桶、作者特征</li>
<li><strong>场景特征</strong>：用户定位、当前时刻、周末节假日、手机品牌型号操作系统</li>
</ul>
<p>特征处理：</p>
<ul>
<li>离散特征：做 embedding</li>
<li>连续特征：分桶，变成离散特征；或其他变换，如 $\log$ 处理等</li>
</ul>
<p><strong>特征缺失</strong>：很多特征无法覆盖所有的样本（如用户没有提供信息等）</p>
<h2>粗排</h2>
<blockquote>
<p>上面的内容主要用于精排，下面是粗排相关的</p>
</blockquote>
<p>粗排一次需要处理几千篇笔记，必须要推理速度快；而精排不需要</p>
<p>回忆：精排可以使用前期融合（特征拼接），精度较高但是推理速度较慢；召回可以使用后期融合（双塔模型后期计算余弦相似度），计算量小但不够精确</p>
<p>粗排可以使用三塔模型：用户塔、物品塔、交叉塔</p>
<p><img src="_img/3-three-tower-model.png" alt="" /></p>
<p>将静态特征（用户特征、场景特征、物品特征）和动态特征（统计特征、交叉特征）分离，并缓存计算量大的物品塔输出向量，可以极大提升计算速度</p>
<p><img src="_img/3-three-tower-model-feature.png" alt="" /></p>
<p>此时模型的计算量集中在上层。</p>
]]></content>
        <author>
            <name>Kinnari</name>
            <uri>https://kinnari-blog.vercel.app/</uri>
        </author>
        <published>2025-07-20T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[推荐系统——特征交叉]]></title>
        <id>https://kinnari-blog.vercel.app/posts/recsys/4-feature-crossing/</id>
        <link href="https://kinnari-blog.vercel.app/posts/recsys/4-feature-crossing/"/>
        <updated>2025-07-20T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[[!NOTE] 这是 b 站王树森推荐系统课程的笔记，记录到了重排，之后的物品冷启动和提分部分我没有学，所以也没有笔记。索引如下：推荐系统...]]></summary>
        <content type="html"><![CDATA[<blockquote>
<p>[!NOTE]
这是 b 站王树森推荐系统课程的笔记，记录到了重排，之后的物品冷启动和提分部分我没有学，所以也没有笔记。索引如下：</p>
<ul>
<li><a href="/posts/recsys/1-intro">推荐系统——概述</a></li>
<li><a href="/posts/recsys/2-retrieval">推荐系统——召回</a></li>
<li><a href="/posts/recsys/3-ranking">推荐系统——排序</a></li>
<li><a href="/posts/recsys/4-feature-crossing">推荐系统——特征交叉</a></li>
<li><a href="/posts/recsys/5-behavior-sequence">推荐系统——行为序列</a></li>
<li><a href="/posts/recsys/6-re-ranking">推荐系统——重排</a></li>
</ul>
</blockquote>
<hr />
<blockquote>
<p><a href="https://www.bilibili.com/video/BV15V4y1x7Ht">https://www.bilibili.com/video/BV15V4y1x7Ht</a></p>
</blockquote>
<h2>Factorized Machine (FM)</h2>
<blockquote>
<p>因式分解机已经不常用了</p>
</blockquote>
<p>对一个特征 $x=[x_{1},x_{2},\dots,x_{d}]$，线性模型为：</p>
<p>$$
p=\sum_{i=1}^{d} w_{i}x_{i}+b
$$</p>
<p>没有引入特征之间的关联，可以引入二次交叉项：</p>
<p>$$
p' = \sum_{i,j}u_{ij}x_{i}x_{j} + p
$$</p>
<p>当特征维度较小时，没有计算上的问题，但是当 $d$ 很大时，就需要计算一个很大的矩阵 $U$。为了避免，可以使用矩阵分解的方式，即将一个 $d\times d$ 的矩阵分解为 $d\times k$ 和 $k\times d$ 的矩阵乘积，$k\ll d$</p>
<blockquote>
<p>觉得熟悉？没错，这正是启蒙了 Lora 的思想！</p>
</blockquote>
<p>此时 $u_{ij}$ 被分解为两个向量的乘积：$u_{ij}=v_{i}^{\top}v_{j}$</p>
<p><img src="_img/4-factorized-machine-1.png" alt="" /></p>
<h2>深度交叉网络 (DCN)</h2>
<p><img src="_img/4-cross-layer.png" alt="交叉层图示" /></p>
<p>公式为：</p>
<p>$$
x_{i+1}=x_{0} \odot (Wx_{i}+b) + x_{i}
$$</p>
<blockquote>
<p>发现了吗，resnet 的残差连接！</p>
</blockquote>
<p>将交叉层堆叠即可得到<strong>交叉网络</strong>（CN）。</p>
<p><img src="_img/4-DCN.png" alt="" /></p>
<h2>LHUC 网络</h2>
<blockquote>
<p>上面的 DCN 在召回和排序中都可以使用，而 LHUC 网络只在精排中有效。全称 Learning Hidden Unit Contributions。</p>
</blockquote>
<p><img src="_img/4-LHUC.png" alt="4-LHUC" /></p>
<p>还有很多其他的结构，如 SENet、Bilinear、FiBiNet 等，自行了解即可</p>
<p><img src="_img/4-senet.png" alt="SENet。注意到了吗，这就是一个 &quot;attention&quot;！" /></p>
]]></content>
        <author>
            <name>Kinnari</name>
            <uri>https://kinnari-blog.vercel.app/</uri>
        </author>
        <published>2025-07-20T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[推荐系统——行为序列]]></title>
        <id>https://kinnari-blog.vercel.app/posts/recsys/5-behavior-sequence/</id>
        <link href="https://kinnari-blog.vercel.app/posts/recsys/5-behavior-sequence/"/>
        <updated>2025-07-20T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[[!NOTE] 这是 b 站王树森推荐系统课程的笔记，记录到了重排，之后的物品冷启动和提分部分我没有学，所以也没有笔记。索引如下：推荐系统...]]></summary>
        <content type="html"><![CDATA[<blockquote>
<p>[!NOTE]
这是 b 站王树森推荐系统课程的笔记，记录到了重排，之后的物品冷启动和提分部分我没有学，所以也没有笔记。索引如下：</p>
<ul>
<li><a href="/posts/recsys/1-intro">推荐系统——概述</a></li>
<li><a href="/posts/recsys/2-retrieval">推荐系统——召回</a></li>
<li><a href="/posts/recsys/3-ranking">推荐系统——排序</a></li>
<li><a href="/posts/recsys/4-feature-crossing">推荐系统——特征交叉</a></li>
<li><a href="/posts/recsys/5-behavior-sequence">推荐系统——行为序列</a></li>
<li><a href="/posts/recsys/6-re-ranking">推荐系统——重排</a></li>
</ul>
</blockquote>
<hr />
<h2>LastN 特征</h2>
<p>用户最近的 $n$ 次交互的物品 ID（点击的最近 $N$ 个，点赞的、收藏的……），embedding 之后求平均/求和/attention 操作等</p>
<h2>DIN 模型</h2>
<p>在 LastN 特征中，用加权平均代替平均（即注意力机制），LastN 向量作为 key 和 value，候选物品向量作为 query</p>
<p>缺点是 $N$ 不能很大，否则计算成本太高，会导致模型过分关注短期兴趣</p>
<h2>SIM 模型</h2>
<p>相较于 SIM 模型，保持一个长期行为记录，共 $n$ 项，每次计算 LastN 特征时，先进行一步筛选（TopK），然后再使用 DIN 模型。筛选可以使用硬筛选（选择相同类目的物品等）和软筛选（近似 $k$ 近邻查找等）。应用 DIN 模型前，需要加入时间信息。</p>
<blockquote>
<p>时间信息？就是 transformer 里面的位置 embedding！</p>
</blockquote>
]]></content>
        <author>
            <name>Kinnari</name>
            <uri>https://kinnari-blog.vercel.app/</uri>
        </author>
        <published>2025-07-20T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[推荐系统——重排]]></title>
        <id>https://kinnari-blog.vercel.app/posts/recsys/6-re-ranking/</id>
        <link href="https://kinnari-blog.vercel.app/posts/recsys/6-re-ranking/"/>
        <updated>2025-07-20T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[[!NOTE] 这是 b 站王树森推荐系统课程的笔记，记录到了重排，之后的物品冷启动和提分部分我没有学，所以也没有笔记。索引如下：推荐系统...]]></summary>
        <content type="html"><![CDATA[<blockquote>
<p>[!NOTE]
这是 b 站王树森推荐系统课程的笔记，记录到了重排，之后的物品冷启动和提分部分我没有学，所以也没有笔记。索引如下：</p>
<ul>
<li><a href="/posts/recsys/1-intro">推荐系统——概述</a></li>
<li><a href="/posts/recsys/2-retrieval">推荐系统——召回</a></li>
<li><a href="/posts/recsys/3-ranking">推荐系统——排序</a></li>
<li><a href="/posts/recsys/4-feature-crossing">推荐系统——特征交叉</a></li>
<li><a href="/posts/recsys/5-behavior-sequence">推荐系统——行为序列</a></li>
<li><a href="/posts/recsys/6-re-ranking">推荐系统——重排</a></li>
</ul>
</blockquote>
<hr />
<p>在粗排、精排每个步骤之后，需要将提取出来的物品进行排序，既需要质量高，又需要多样性好（注意粗排之后也需要）。</p>
<blockquote>
<p>怎么突然联想到国外招生的 diversity 要求了（</p>
</blockquote>
<p>对图文笔记形式的物品，可以使用 CLIP 模型计算相似度，从而估计多样性。</p>
<p><img src="_img/6-post-processing.png" alt="" /></p>
<h2>Maximal Marginal Relevance (MMR) 算法</h2>
<p>有 $n$ 个物品，打分为 $r_{1},r_{2},\dots,r_{n}$，物品 $i,j$ 的相似度为 $\text{sim}(i,j)$</p>
<p>这 $n$ 个物品中选出的物品集合为 $\mathcal{S}$，未选中的物品集合为 $\mathcal{R}$，计算 $\mathcal{R}$ 中每一个物品的 marginal relevance 分数：</p>
<p>$$
\text{MR}<em>{i} = \theta \cdot r</em>{i} - (1-\theta)\cdot \max_{j\in \mathcal{S}} \text{sim}(i,j)
$$</p>
<p>$\theta$ 为超参，选择使 $\mathrm{MR}_{i}$ 最大的物品从 $\mathcal{R}$ 放入 $\mathcal{S}$，反复进行这个操作，直到选出需要数量的物品。</p>
<p>缺点：当 $\mathcal{S}$ 非常大时，最大相似性 $\max_{_{j\in \mathcal{S}}}\mathrm{sim}(i,j)$ 会很大（接近 $1$），导致算法退化，多样性变差。可以使用滑动窗口，即使用最近选入的若干物品集合代替全集 $\mathcal{S}$（可以理解为离得远的物品可以相似，因为用户感觉不到这种相似性）。</p>
<p>可以对 $\mathcal{S}$ 进行一些规则筛选，以适合具体业务</p>
<h2>DPP 算法</h2>
<p>给定 $k$ 个物品，表征为单位向量 $v_{1},v_{2},\dots,v_{k}\in \mathbb{R}^{d},d\geq k$，作为矩阵 $V\in \mathbb{R}^{d\times k}$ 的列，可以计算这些向量构成超平行 $d$ 维体的体积 $\text{vol}(\mathcal{P}(v_{1},\dots,v_{k}))$ 来衡量多样性，而</p>
<p>$$
\det(V^{\top}V) = \text{vol}(\mathcal{P}(v_{1},\dots,v_{k})^{2}
$$</p>
<p>故可以通过计算行列式来衡量多样性，目标函数：</p>
<p>$$
\arg\max_{\mathcal{S}:\lvert \mathcal{S} \rvert =k} \log \det(V_{\mathcal{S}}^{\top}V_{\mathcal{S}})
$$</p>
<p>应用在 MMR 算法上，就是：</p>
<p>$$
\arg\max_{\mathcal{S}:\lvert \mathcal{S} \rvert =k} \theta \cdot\left( \sum_{j\in \mathcal{S}}r_{j} \right) + (1-\theta)\cdot \log \det(V_{\mathcal{S}}^{\top}V_{\mathcal{S}})
$$</p>
<p>记 $A_{\mathcal{S}}=V_{\mathcal{S}}^{\top}V_{\mathcal{S}}$，则 $a_{ij}=v_{i}^{\top}v_{j}$，使用<strong>贪心</strong>寻找下一个物品：</p>
<p>$$
\arg\max_{i\in \mathcal{R}} \theta \cdot r_{i} + (1-\theta)\cdot \log \det(A_{\mathcal{S}\cup { i }})
$$</p>
<p>（为了快速求解这个式子有很多数学推导，这里就不记录了）</p>
]]></content>
        <author>
            <name>Kinnari</name>
            <uri>https://kinnari-blog.vercel.app/</uri>
        </author>
        <published>2025-07-20T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[推荐系统——召回]]></title>
        <id>https://kinnari-blog.vercel.app/posts/recsys/2-retrieval/</id>
        <link href="https://kinnari-blog.vercel.app/posts/recsys/2-retrieval/"/>
        <updated>2025-07-17T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[[!NOTE] 这是 b 站王树森推荐系统课程的笔记，记录到了重排，之后的物品冷启动和提分部分我没有学，所以也没有笔记。索引如下：推荐系统...]]></summary>
        <content type="html"><![CDATA[<blockquote>
<p>[!NOTE]
这是 b 站王树森推荐系统课程的笔记，记录到了重排，之后的物品冷启动和提分部分我没有学，所以也没有笔记。索引如下：</p>
<ul>
<li><a href="/posts/recsys/1-intro">推荐系统——概述</a></li>
<li><a href="/posts/recsys/2-retrieval">推荐系统——召回</a></li>
<li><a href="/posts/recsys/3-ranking">推荐系统——排序</a></li>
<li><a href="/posts/recsys/4-feature-crossing">推荐系统——特征交叉</a></li>
<li><a href="/posts/recsys/5-behavior-sequence">推荐系统——行为序列</a></li>
<li><a href="/posts/recsys/6-re-ranking">推荐系统——重排</a></li>
</ul>
</blockquote>
<hr />
<h2>基于物品的协同过滤 (ItemCF)</h2>
<h3>原理</h3>
<blockquote>
<ol>
<li>认为用户会喜欢相似的物品</li>
<li>喜欢两个物品的用户重叠很大，则两个物品相似</li>
</ol>
</blockquote>
<p>已知物品 $i_{1},i_{2},\dots$，对任意一个用户 $u$ 以及一个新物品 $i$，计算 $u$ 对 $i$ 的兴趣：</p>
<p>$$
\text{like}(u,i)=\sum_{j} \text{like}(u,i_{j})\cdot \text{sim}(i_{j}, i)
$$</p>
<p>其中</p>
<ul>
<li>$\text{like}(u,i_{j})$ 通过收藏转发等方式计算</li>
<li>$\text{sim}(i_{j},i)$：对任意一个物品 $i$，记所有对 $i$ 感兴趣的用户为 $U={u_{1},u_{2},\dots}$，构造对应的向量，每一维放用户对 $i$ 的喜爱分数 $\text{like}(u_{j},i)$，然后对 $i_{j}$ 和 $i$ 计算余弦相似度得到（而不是根据物品内容进行计算）即</li>
</ul>
<p>$$
\text{sim}(i_{1},i_{2})=\frac{\sum_{u\in V}\text{like}(u,i_{1})\cdot \text{like}(u, i_{2})}{\sqrt{ \sum_{u\in U_{1}}\text{like}^{2}(u,i_{1})\sum_{u\in U_{2}}\text{like}^{2}(u,i_{2}) }}
$$</p>
<p>其中 $V=U_{1}\cap U_{2}$，不考虑喜欢程度的话：</p>
<p>$$
\text{sim}(i_{1},i_{2})=\frac{\lvert V \rvert }{\sqrt{ \lvert U_{1} \rvert \cdot \lvert U_{2} \rvert  }}
$$</p>
<h3>完整过程</h3>
<ol>
<li>事先做<strong>离线计算</strong>，建立“用户 -&gt; 物品”索引（最近点击、交互过的物品 ID 及喜爱程度）、“物品 -&gt; 物品”索引（两两之间相似度，每个物品最相似的 $k$ 个物品）</li>
<li><strong>线上召回</strong>
<ol>
<li>对一个用户，取最近感兴趣的物品列表（$n$ 个）</li>
<li>对取出的 $n$ 个物品，找到 top-$k$ 相似物品（最多 $n\cdot k$ 个，因为要去重）</li>
<li>对相似物品进行计算，返回前若干个物品</li>
</ol>
</li>
</ol>
<p>特点：使用索引，离线计算量大，线上计算量小</p>
<h2>Swing 召回通道</h2>
<blockquote>
<ol>
<li>ItemCF 的问题：如果有一个小圈子，以及两个不相关的物品 $i_{1},i_{2}$，圈子内的两个用户分别将这两个物品转发到圈子内，导致大量用户同时点开了这两个物品，会造成这两个物品的相似度估计不准确</li>
<li>Swing 认为两个用户同时感兴趣的物品越多，则越有可能是这样的小圈子里面的人，则应该降低权重</li>
</ol>
</blockquote>
<p>对两个物品 $i_{1},i_{2}$，同上记 $V=U_{1}\cap U_{2}$，对 $V$ 中用户 $u_{1},u_{2}$，重合度记为 $\text{overlap}(u_{1},u_{2})$，由同时感兴趣的物品决定大小，同时感兴趣的物品越多，则 overlap 越大。计算相似度：</p>
<p>$$
\text{sim}(i_{1},i_{2})=\sum_{u_{1},u_{2}\in V} \frac{1}{\alpha+\text{overlap}(u_{1},u_{2})}
$$</p>
<h2>基于用户的协同过滤（UserCF）</h2>
<h3>原理</h3>
<blockquote>
<p>认为如果两个用户相似，则他们喜欢的物品也应该相似</p>
</blockquote>
<p>和 ItemCF 基本一致，但是使用<strong>用户之间的相似度</strong>替代物品之间的相似度。</p>
<p>$$
\text{like}(u,i)=\sum_{j}\text{sim}(u,u_{j})\cdot \text{like}(u_{j},i)
$$</p>
<p>先不考虑物品热门程度：</p>
<p>$$
\text{sim}(u_{1},u_{2})=\frac{\lvert J \rvert }{\sqrt{ \lvert I_{1} \rvert \cdot \lvert I_{2} \rvert  }}
$$</p>
<p>其中 $J=I_{1}\cap I_{2}$。但是一个物品越热门，就越容易被两个人同时喜欢，则这两个人的（平均）相似度就越低，所以需要赋予热门权重，此时</p>
<p>$$
\text{sim}(u_{1},u_{2})=\frac{\sum_{l\in J} \frac{1}{\log(1+n_{l})}}{\sqrt{ \lvert I_{1} \rvert \cdot \lvert I_{2} \rvert  }}
$$</p>
<p>其中 $n_{l}$ 是喜欢物品 $l$ 的人数</p>
<h3>完整过程</h3>
<p>和 ItemCF 仅有少数替换上的不同，不做赘述。</p>
<h2>矩阵补充（Matrix Completion）</h2>
<p>将用户、物品都 embedding 之后，计算 embedding 向量的内积，值越大说明越感兴趣。</p>
<p><img src="_img/mc_model.png" alt="" /></p>
<p><strong>数据集</strong> $\Omega$：（用户，物品，兴趣分数）$(u,i,y)$ 的三元组集合，兴趣分数 $y$ 通过系统指标计算</p>
<p><strong>训练</strong>：将 $u$ 号用户映射为向量 $a_{u}$，第 $i$ 号物品映射为向量 $b_{i}$，分别可以组成 embedding 矩阵 $A,B$，求解优化问题：</p>
<p>$$
\min_{A,B} \sum_{(u,i,y)\in\Omega} (y-a_{u}^{\top}b_{i})^{2}
$$</p>
<p><strong>存储</strong>：训练得到的矩阵 $A$ 和 $B$，将 $A$ 的列存储为 key-value 表（用户 ID - embedding 向量）</p>
<p><strong>线上服务</strong>：对用户 $u$，通过计算内积选取内积最大的 $k$ 个物品，但是这样时间复杂度和物品数量呈线性关系，在实际中无法 work，可以使用近似最近邻查找（<em>Approximate Nearest Neighbor Search</em>），可以得到比最优结果稍弱的结果</p>
<p><img src="./_img/matrix_completion.png" alt="" /></p>
<blockquote>
<p>实践中矩阵补充并不能 work，因为</p>
<ol>
<li>只用了 ID embedding，而没有利用物品、用户属性等信息</li>
<li>负样本的选取方式不对</li>
<li>做训练的方法不好（内积不如余弦相似度，平方损失不如交叉熵损失）</li>
</ol>
</blockquote>
<h2>双塔模型</h2>
<h3>模型</h3>
<p>对物品/用户特征的处理：</p>
<ol>
<li><strong>ID</strong>：同矩阵补充，embedding 成一个向量</li>
<li><strong>离散特征</strong>：要么 embedding（维数过大），要么直接使用 one-hot 编码（维数不大）</li>
<li><strong>连续特征</strong>：归一化、log 处理、分桶等</li>
</ol>
<p>然后将得到的向量拼接之后，送入一个神经网络，输出最终的表征</p>
<p><img src="_img/2-stmx-feature-process.png" alt="" /></p>
<p>然后使用余弦相似度计算喜爱程度，和矩阵补充就一样了</p>
<h3>训练</h3>
<ol>
<li>Pointwise：独立看待每个正负样本，做简单的二元分类（正样本尽可能接近 1，负样本则 -1）</li>
<li>Pairwise：每次各取一个正负样本
<ol>
<li>鼓励 $\cos(a,b^{+})$ 大于 $\cos(a,b^{-})$，使用 triplet hinge loss：$L(a,b^{+},b^{-})=\max_{}{ 0,\cos(a,b^{-})+m-\cos(a,b^{+}) }$，$m$ 为超参数，即希望正样本至少比负样本大 $m$</li>
<li>也有其他 loss，如 triplet logistic loss：$L(a,b^{+},b^{-})=\log(1+\exp(\sigma \cdot(\cos(a,b^{-})-\cos(a, b^{+}))))$，$\sigma&gt;0$ 为超参</li>
</ol>
</li>
<li>Listwise：一个正，多个负
<ol>
<li>鼓励 $\cos(a,b^{+})$ 尽可能大，$\cos(a,b_{i}^{-})$ 尽可能小，见下图</li>
</ol>
</li>
</ol>
<p><img src="_img/2-stmx-listwise.png" alt="" /></p>
<h3>正负样本</h3>
<p><strong>正样本</strong>：曝光且用户点击过的物品。由于热门物品更容易被点击，所以需要过采样冷门物品或者降采样热门物品</p>
<p><strong>负样本</strong>：</p>
<p><img src="_img/2-stmx-neg-sample.png" alt="" /></p>
<ol>
<li>简单负样本（没有被召回的物品），都需要注意热门/冷门物品的 2-8 效应影响：
<ol>
<li>全体样本</li>
<li>batch 内样本（使用 list-wise 方式训练时，会过度打压热门物品，需要纠偏：对用户 $a_{i}$ 和物品 $b_{j}$，$b_{j}$ 被抽样到的概率为 $p_{j}$，训练的时候，将 $\cos(a_{i},b_{j})$ 替换为 $\cos(a_{i},b_{j})-\log p_{j}$，线上召回时不需要替换）</li>
</ol>
</li>
<li>困难负样本
<ol>
<li>被粗排淘汰的物品（较困难）</li>
<li>精排分数后，靠后的物品（非常困难）</li>
</ol>
</li>
<li><strong>错误负样本</strong>：曝光但是没有点击的物品，是<strong>错误</strong>的！这些物品用于排序训练，而不是召回。
<ol>
<li>因为召回的目标是“快速找到用户可能感兴趣的物品”，这些错误负样本只能说明用户对别的更感兴趣，或者碰巧没有点击，不应该用于召回训练，但可以用于排序训练。</li>
</ol>
</li>
</ol>
<p>一般将简单负样本和困难负样本按 50% 来混合，进行训练</p>
<h3>召回</h3>
<ol>
<li>在上线前，首先计算物品塔，得到物品的向量并储存在向量数据库中</li>
<li>上线后，对需要的用户，线上计算向量，然后去数据库中检索物品
<ol>
<li>因为用户特征时刻在发生变化且变化较快，事先计算存储意义不大，成本也太高；物品的特征相对稳定</li>
<li>每次召回只使用一个用户向量，但是会涉及到上亿个物品，所以事先计算物品向量并存储</li>
</ol>
</li>
</ol>
<h3>模型更新</h3>
<ul>
<li><strong>全量更新</strong>：例如在凌晨，使用上一天全天的数据，在上一天的模型参数的基础上继续训练，训一个 epoch。得到新的用户塔和物品向量</li>
<li><strong>增量更新</strong>：即 online learning，实时收集数据，增量更新 ID embedding 参数（不更新其他部分的参数，工程上的经验），会有一定的延迟，例如十多分钟
<ul>
<li>注意全量更新的模型不使用增量更新得到的模型，而是使用<em>上一次全量更新后得到的模型</em></li>
<li>只做增量更新是有偏的，因此需要全量更新，随机打乱一天的数据，消除偏差</li>
</ul>
</li>
</ul>
<h2>双塔模型 + 自监督</h2>
<blockquote>
<p>双塔模型学不好低曝光物品的表征，引入自监督可以缓解这个问题</p>
</blockquote>
<p><strong>特征变换</strong>：</p>
<ol>
<li><strong>random mask</strong>：随机选择离散特征并 mask 掉</li>
<li><strong>dropout</strong>：对多值的离散特征</li>
<li><strong>互补特征</strong>：将物品的特征进行随机分组，每一组中先随机舍弃一种特征，然后重新舍弃该组的其他特征（即互补特征）。如特征 ${ a,b,c,d }$ 划分为两组 ${ a,b },{ c,d }$，第一次舍弃 $b,d$，第二次舍弃 $a,c$，会得到两个对同一物品的表征，希望能够足够接近</li>
<li><strong>mask 一组关联的特征</strong>：
<ol>
<li>对特征 $u,v$，通过计算互信息来衡量相关性，即 $MI(U,V)=\sum_{u\in U}\sum_{v\in V}p(u,v)\log \frac{p(u,v)}{p(u)p(v)}$</li>
<li>先离线算出所有特征的互信息矩阵，然后训练时随机选取一个特征，mask 掉和它最相关的若干特征，然后进行训练</li>
<li>效果比上面三个更好，但是更复杂，不易维护</li>
</ol>
</li>
</ol>
<p>将不同物品经过相同的几个特征变换，这里假设两个，从物品 $a \to a',a''$ 以及 $b\to b',b''$，那么自然希望 $a'$ 和 $a''$ 相似度尽可能高，而 $a'$ 和 $b',b''$ 相似度尽可能低。具体训练时，对每一个 batch 做两类特征变换，得到样本 $b_{1}',b_{2}',\dots,b_{m}'$ 和 $b_{1}'',b_{2}'',\dots,b_{m}''$，第 $i$ 个物品的损失函数：</p>
<p>$$
\mathcal{L}<em>{i} = -\log\left( \frac{\exp(\cos(b</em>{i}',b_{i}''))}{\sum_{j=1}^{m} \exp(\cos(b_{i}',b_{j}''))} \right)
$$</p>
<p>也就是和 Listwise 的训练方式类似的损失函数。这个损失将和双塔模型的损失加权得到最终损失</p>
<h2>Deep Retrieval</h2>
<blockquote>
<p>将物品表征为<strong>路径</strong>，线上查找用户最匹配的路径</p>
</blockquote>
<p>首先是路径的定义：想象一个 3 层，每层有 $K$ 个元素 ${ 1,2,3,\dots K }$ 的结构，那么一个三元组 $[a,b,c]\in [K]^{3}$ 就是一条路径。</p>
<p>然后是路径和物品之间的映射：</p>
<ol>
<li>一个物品可以对应多条路径</li>
<li>一条路径对应了一组物品</li>
</ol>
<p>之后就可以预估用户对路径的兴趣：给定用户特征 $x$，计算 $p(a\mid x)$，然后计算下一层 $p(b\mid a,x)$，以此类推（即链式计算）得到 $p(a,b,c\mid x)$。选择每一层的节点时，可以使用 beam search 的方法。每一次计算都使用一个对应的神经网络。得到最感兴趣的一条路径，就可以取出一组物品了（如果过多，则用一个小的排序模型进行打分后取出需要的数量）。</p>
<p><img src="_img/deep-retrieval.png" alt="" /></p>
<p><strong>训练</strong>：</p>
<ol>
<li>只用正样本，即 $\text{clike}(\text{user},\text{item})=1$ 的二元组 $(\text{user},\text{item})$</li>
<li>神经网络：物品表征为 $J$ 条路径，$[a_{1},b_{1},c_{1}],\dots,[a_{J},b_{J},c_{J}]$，损失函数：$-\log \sum_{j=1}^{J}p(a_{j},b_{j},c_{j}\mid x)$</li>
<li>物品表征：对路径 $p=[a,b,c]$ 和物品 $i$，相关度 $\text{score}(p,i)=\sum_{u} p(a,b,c\mid u) \cdot \text{click}(u,i)$，根据这个值选出 $J$ 条路径作为物品的表征（记为集合 $\Pi$）损失函数：$\text{loss}(i,\Pi)=-\log \sum_{j=1}^{J}\text{score}(p_{j},i)$
<ol>
<li>为了避免过多物品集中在一条路径上：正则化，取一条路径上的物品数量的四次方</li>
</ol>
</li>
</ol>
<h2>其他召回通道</h2>
<ul>
<li>地理位置召回：如 GeoHash 召回/同城召回，对一个区域内的<strong>优质</strong>笔记，取出最新的若干篇（因为没有个性化，所以需要优质），按新到旧的顺序排序</li>
<li>作者召回：关注的作者，或者有交互的作者，相似作者</li>
<li>缓存召回：复用前 $n$ 次推荐精排的结果</li>
</ul>
<h2>曝光过滤</h2>
<blockquote>
<p>看过某个物品，则不再把该物品推荐给该作者（不过有例外，例如长视频平台可以重复推荐相同视频）</p>
</blockquote>
<p>对一个用户，看过 $n$ 个物品，本次召回 $r$ 个物品，则暴力对比的话需要 $O(nr)$ 时间，不能接受。</p>
<p>使用 <strong>bloom filter</strong> 结构：如果判断为 no，则<strong>一定不</strong>在集合中；反之，则<strong>很可能</strong>在集合中。具体的：</p>
<ol>
<li>把物品集合表征为一个 $m$ 维01向量：每个用户的曝光物品的集合（共 $n$ 个物品）表征为 $m$ bit 的向量存储</li>
<li>Bloom filter 用 $k$ 个哈希，每个哈希把物品 ID 映射为 0 到 $m-1$ 之间的整数</li>
<li>误判的概率为：$\delta \approx\left( 1-\exp\left( -\frac{kn}{m} \right) \right)^{k}$，最优参数为 $k=1.44\ln\left( \frac{1}{\delta} \right), m=2n\ln\left( \frac{1}{\delta } \right)$</li>
</ol>
<p>对一个哈希的情况：</p>
<p><img src="_img/bloom-filter-k=1.png" alt="" /></p>
<p>而对 $k=3$ 的情况：</p>
<p><img src="_img/bloom-filter-k=3.png" alt="" /></p>
<p>总链路：</p>
<p><img src="_img/bloom-filter-link.png" alt="" /></p>
<p>缺点是不支持删除物品，对时间过久的物品也会记录在案，影响误伤率。需要定期重算。</p>
]]></content>
        <author>
            <name>Kinnari</name>
            <uri>https://kinnari-blog.vercel.app/</uri>
        </author>
        <published>2025-07-17T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[推荐系统——概述]]></title>
        <id>https://kinnari-blog.vercel.app/posts/recsys/1-intro/</id>
        <link href="https://kinnari-blog.vercel.app/posts/recsys/1-intro/"/>
        <updated>2025-07-13T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[[!NOTE] 这是 b 站王树森推荐系统课程的笔记，记录到了重排，之后的物品冷启动和提分部分我没有学，所以也没有笔记。索引如下：推荐系统...]]></summary>
        <content type="html"><![CDATA[<blockquote>
<p>[!NOTE]
这是 b 站王树森推荐系统课程的笔记，记录到了重排，之后的物品冷启动和提分部分我没有学，所以也没有笔记。索引如下：</p>
<ul>
<li><a href="/posts/recsys/1-intro">推荐系统——概述</a></li>
<li><a href="/posts/recsys/2-retrieval">推荐系统——召回</a></li>
<li><a href="/posts/recsys/3-ranking">推荐系统——排序</a></li>
<li><a href="/posts/recsys/4-feature-crossing">推荐系统——特征交叉</a></li>
<li><a href="/posts/recsys/5-behavior-sequence">推荐系统——行为序列</a></li>
<li><a href="/posts/recsys/6-re-ranking">推荐系统——重排</a></li>
</ul>
</blockquote>
<hr />
<blockquote>
<p>这部分偏导论，随便记录了一点</p>
</blockquote>
<h2>基本概念</h2>
<ul>
<li>用户行为：点击、点赞、收藏、转发</li>
<li>消费指标：点击率 (click rate)、交互率 (engagement rate)</li>
<li>北极星指标：用户规模、消费、发布</li>
<li>实验流程：离线实验、AB测试、推全</li>
</ul>
<h2>链路</h2>
<ul>
<li>召回（retrieval）：快速从海量数据中取回几千个用户可能感兴趣的物品。</li>
<li>粗排：用小规模的模型的神经网络给召回的物品打分，然后做截断，选出分数最高的几百个物品。</li>
<li>精排：用大规模神经网络给粗排选中的几百个物品打分，可以做截断，也可以不做截断。</li>
<li>重排：对精排结果做多样性抽样，得到几十个物品，然后用规则调整物品的排序。</li>
</ul>
<h2>A/B 测试</h2>
<p>分桶</p>
<p>分层实验：召回、粗排、精排、重排、用户界面、广告……</p>
<p>同层互斥、不同层正交</p>
<p>同类策略互斥：天然互斥（不同结构）；效果相互增强或者相互抵消
不同类型策略：通常不会相互干扰，可以作为正交的两层</p>
<hr />
<p>Holdout 机制</p>
<p>同一时间有很多实验，可以留 10% 的用户作为 holdout 桶，只用剩下的 90% 用户做实验，结果和 holdout 桶进行对比，就可以验证实验是否有效。</p>
<p>还有其他 holdout 的方法，比如每个用户留 k 个行为、根据时间顺序划分训练/测试等</p>
<p>每个考核周期结束后，重置 holdout 桶</p>
<hr />
<p>实验推全</p>
<p>新建一个推全层，和其他层正交</p>
<p>从小流量实验到推全后，diff 会上升（如线性提升），可能需要做归一</p>
<hr />
<p>反转实验</p>
<p>有指标会有滞后性，需要长期观测；但公司需要尽可能快地推全上线（因为可以腾出桶给其他实验用，以及后续开发）</p>
<p>解决方法是在推全的新层中开一个旧策略的桶，长期观测实验指标的 diff</p>
]]></content>
        <author>
            <name>Kinnari</name>
            <uri>https://kinnari-blog.vercel.app/</uri>
        </author>
        <published>2025-07-13T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[CS168 Proj1 - Traceroute]]></title>
        <id>https://kinnari-blog.vercel.app/posts/computer-network-notes/project-1/</id>
        <link href="https://kinnari-blog.vercel.app/posts/computer-network-notes/project-1/"/>
        <updated>2025-06-23T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[在 intro 中介绍了 TTL，而 traceroute 利用 TTL 来实现探测网络路径的功能。笼统来说就是从较小的 TTL 逐渐变大...]]></summary>
        <content type="html"><![CDATA[<p>在 <a href="../1-intro/">intro</a> 中介绍了 <strong>TTL</strong>，而 traceroute 利用 TTL 来实现探测网络路径的功能。笼统来说就是从较小的 TTL 逐渐变大，传输一个 UDP-over-IP 的 packet（称为 probe），在中途某个 router 发现 TTL 失效后，再传回一个 ICMP 的 Time Exceeded 报文，从而得知经过了哪些 router。具体实现见 <a href="https://sp25.cs168.io/proj1/guide/#2-introducing-traceroute">https://sp25.cs168.io/proj1/guide/#2-introducing-traceroute</a>。</p>
<p>:::warning[问题]</p>
<ol>
<li>网络是 best-effort 的，可能会丢包</li>
<li>多条路径</li>
</ol>
<p>可以用重复探测（repeated probing）来解决。
:::</p>
<p>:::warning[问题]
end hosts 可能不会返回消息，导致不知道是否成功到达目的地。
解决方法是故意访问无法访问的端口，使用 <strong>port unreachable</strong> 的 ICMP 报文来告知发送方。端口号 33434 常被设置为从不使用。
:::</p>
<p>Proj1 就是用 python 来实现 traceroute 的功能。</p>
<h2>Part A</h2>
<h3>Stage 1</h3>
<p><code>traceroute</code> 函数实现：</p>
<pre><code>def traceroute(sendsock: util.Socket, recvsock: util.Socket, ip: str) \
        -&gt; list[list[str]]:
    ttl = 30
    sendsock.set_ttl(ttl)
    sendsock.sendto("Potato".encode(), (ip, TRACEROUTE_PORT_NUMBER))
    if recvsock.recv_select():
        buf, addr = recvsock.recvfrom()
        print(f"Packet bytes: {buf.hex()}")
    return []
</code></pre>
<p>得到输出：</p>
<pre><code>traceroute to cmu.edu (128.2.42.10)
Packet bytes: 4500003878084000e40199dd80022a0a0ab4d01e0303bb3b0000000045000022693b4000041188b10ab4d01e80022a0ae4de829a000eda39
</code></pre>
<p>给 https://hpd.gasmi.net/ 解码后得到：<code>128.2.42.10 → 10.180.208.30 ICMP Destination unreachable (Port unreachable) </code></p>
<p>设置不同的 ttl 以得到不同的结果。</p>
<blockquote>
<p>[!NOTE]
这个实验里面的 routers 默认使用大端法，并且长度按照 header 和 payload 的总长度来计算。</p>
</blockquote>
<h3>Stage 2</h3>
<p>上一个 stage 中实现了数据传输，这个 stage 负责实现数据的解析。只需要根据提示和 https://sp25.cs168.io/proj1/guide/#3-header-structure 实现 IPv4、UDP、ICMP 的 <code>__init__</code> 函数即可。</p>
<pre><code>class IPv4:
    def __init__(self, buffer: bytes):
        b = ''.join(format(byte, '08b') for byte in [*buffer])
        self.version = int(b[0:4], 2)
        self.header_len = int(b[4:8], 2) * 4
        self.tos = int(b[8:16], 2) 
        self.length = int(b[16:32], 2) 
        self.id = int(b[32:48], 2)
        self.flags = int(b[48:51], 2)  
        self.frag_offset = int(b[51:64], 2)
        self.ttl = int(b[64:72], 2)
        self.proto = int(b[72:80], 2)
        self.cksum = int(b[80:96], 2)
        self.src = '.'.join(str(int(b[i:i+8], 2)) for i in range(96, 128, 8))
        self.dst = '.'.join(str(int(b[i:i+8], 2)) for i in range(128, 160, 8))

class ICMP:
    def __init__(self, buffer: bytes):
        b = ''.join(format(byte, '08b') for byte in [*buffer])
        self.type = int(b[0:8], 2)
        self.code = int(b[8:16], 2)
        self.cksum = int(b[16:32], 2)

class UDP:
    def __init__(self, buffer: bytes):
        b = ''.join(format(byte, '08b') for byte in [*buffer])
        self.src_port = int(b[0:16], 2)
        self.dst_port = int(b[16:32], 2)
        self.len = int(b[32:48], 2)
        self.cksum = int(b[48:64], 2)
</code></pre>
<h3>Stage 3</h3>
<p>这个 stage 则将上两个 stage 的功能进行结合，实现一个基本的 traceroute！（异常处理在 part B 实现）</p>
<p>代码：</p>
<pre><code>def traceroute(sendsock: util.Socket, recvsock: util.Socket, ip: str) \
        -&gt; list[list[str]]:
    paths = []
    for ttl in range(1, TRACEROUTE_MAX_TTL+1):
        # init
        routers = []
        recv_ip = None
        for _ in range(PROBE_ATTEMPT_COUNT):
            sendsock.set_ttl(ttl)
            sendsock.sendto("Potato".encode(), (ip, TRACEROUTE_PORT_NUMBER))
            if recvsock.recv_select():
                _, addr = recvsock.recvfrom()
                recv_ip = addr[0]
                if recv_ip not in routers:
                    routers.append(recv_ip)
                if recv_ip == ip:
                    break
        # result
        paths.append(routers)
        util.print_result(routers, ttl)
        if recv_ip == ip:
            break
    return paths
</code></pre>
<p>完整输出</p>
<pre><code>traceroute to cmu.edu (128.2.42.10)
 1: *
 2: 10.3.2.217
 3: 10.3.0.26
 4: 10.3.0.73
 5: 10.255.38.54
 6: 10.255.38.73
 7: 10.255.38.90
 8: 58.247.64.114
 9: 139.226.203.117
10: *
11: *
12: 219.158.5.158
13: 219.158.3.178
14: 219.158.117.2
15: *
16: *
17: *
18: *
19: *
20: *
21: *
22: ae26.mpr2.pit1.us.zip.zayo.com (64.125.22.103)
23: 209.249.178.189.IDIA-397525-ZYO.zip.zayo.com (209.249.178.189)
24: *
25: CORE2-BORDER-FW.GW.CMU.NET (128.2.5.68)
26: POD-D-CYH-9500-CORE2.GW.CMU.NET (128.2.255.13)
27: CMU-VIP.ANDREW.CMU.EDU (128.2.42.10)
</code></pre>
<h2>Part B</h2>
<p>To be done...</p>
]]></content>
        <author>
            <name>Kinnari</name>
            <uri>https://kinnari-blog.vercel.app/</uri>
        </author>
        <published>2025-06-23T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[计网笔记一 概述]]></title>
        <id>https://kinnari-blog.vercel.app/posts/computer-network-notes/1-intro/</id>
        <link href="https://kinnari-blog.vercel.app/posts/computer-network-notes/1-intro/"/>
        <updated>2025-06-21T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[计算机网络的特点：federated，scalable，constantly evolving，diverse，asynchronous，h...]]></summary>
        <content type="html"><![CDATA[<h2>计算机网络的层级结构</h2>
<p>计算机网络的特点：federated，scalable，constantly evolving，diverse，asynchronous，handle failures at scale。</p>
<p>由于计算机网络非常复杂，所以一般会分为若干层进行设计。每一层只需要保证自己的功能实现正确，而不需要关心其他层的实现细节。这样可以使得网络设计更加模块化，便于维护和扩展。</p>
<p>一共 5 层：</p>
<ol>
<li>物理层（Physical, layer 1）：通过物理的方式实现 bit 的传输，可以是有线的（wire）、无线的（wireless）、光信号（optical fiber）等</li>
<li>数据链路层（Link, layer 2）：通过物理层的连接，将一个区域内不同的设备连接（link）起来，就构成了一个局域网，称为链路层。设备间可以传输 packets（一组 bit，代表某种信息，在这一层通常被称为 frames）</li>
<li>网络层（Network, layer 3）：不同的局域网之间需要进行通讯，网络层就负责将数据从一个网络传输到另一个网络，使用 IP 协议来实现。又因为链路层一次传递数据有上限，所以网络层还实现了分片（fragmentation）和重组（reassembly）的功能。是一个 best-effort的服务（尽力服务），即不保证数据能被正确传输、不保证交付失败时可以汇报错误等，它只负责传输。</li>
<li>传输层（Transport, layer 4）：网络层只负责传输，而无法保证数据的可靠性（如顺序、完整性等），传输层负责提供可靠的数据传输服务，常用的协议有 TCP（面向连接）和 UDP（无连接）。并且传输层将网络层的 packet 分成更小的片段，以便于传输和处理。传输层还负责端到端的通讯，提供了流量控制、拥塞控制等功能。它可以保证数据的可靠传输，但也会增加延迟。这一层将数据传输从数据包（packet）的概念转换到流（flow）的概念。</li>
<li>应用层（Application, layer 7）：负责在不改变网络架构的前提下，构造不同的服务（service）。将应用程序的数据转换为网络可以传输的格式，并提供用户接口。常见的协议有 HTTP、FTP、SMTP 等。在旧式的网络模型里面这是第 7 层，所以现在有时仍然称为第 7 层。</li>
</ol>
<p><img src="./_images/network-1-3-layer.png" alt="前三层的示意图。一块灰色区域是一个局域网，局域网之间通过路由（router，也被称为 switch 用方块代表）来连接" /></p>
<p><img src="./_images/network-layer-3.png" alt="internet 即为 network of networks。从一个设备发送数据到另一个设备，需要经过若干路由" /></p>
<p><img src="./_images/network-infra.png" alt="" /></p>
<p><strong>Header File</strong>：如果只传输数据，接收方无法知道数据的信息以及如何处理数据。为此需要添加 headers，包含源地址、目的地址、协议类型、校验和（checksum）等元数据（metadata），被添加到数据（payload）的前面，形成一个完整的 packet。</p>
<p>由于有多个层级，每个层级都会添加自己的 header，所以会有 <strong>multiple headers</strong> 的现象。每一层只关注当前层对应的 header 内容。对于 endpoints(end hosts)，有四层 header 被使用，而对于 router，则只有 3 个。在 router 之间传输时，最外层的 header 会被修改，比方说从 A 到 B 到 C，在 router A 处 header 内容可能为 <code>From: A, To: B</code>，而在 router B 处则会被修改为 <code>From: B, To: C</code>。</p>
<p><img src="./_images/multiple-headers.png" alt="Router 不关心 layer 4 和 7 的内容，而 end hosts 会关心所有 header 的内容，留意数据传输的路径" /></p>
<blockquote>
<p>[!NOTE] TTL (Time to Live)</p>
<p>在 layer 3 的 header 中有一个字段叫做 TTL (Time to Live)，用于防止由于 router 的循环传输导致数据无法正确传输的问题。初始时，TTL 被初始化为一个较大的值，当经过每个 router 时，TTL 会减 1，当 TTL 减到 0 时，数据包就被丢弃（使用 IMCP 协议进行错误消息返回）。</p>
</blockquote>
<h2>设计准则</h2>
<blockquote>
<p>上一节介绍了计算机网络的结构，这一节讲解设计这种结构时使用的设计原则。</p>
</blockquote>
<p>一些 guidelines：</p>
<ol>
<li><strong>去中心化控制</strong>（decentralized control）：也可以是 SDN(Software Defined Networking)，进行中心化控制。但是 DSDN(Distributed SDN) 又回到了去中心化控制。</li>
<li><strong>尽力服务模型</strong>（best-effort service model）：如 layer 3 (internet)；也可以引入一些质量保证（Quality of Service, QoS）。</li>
<li><strong>绕过麻烦</strong>（route around trouble）：网络对 failures 需要弹性（resilient），如当一个 router 失败时，能找到其他路径进行传输。</li>
<li><strong>愚蠢基础设施</strong>（dumb infrastructure with smart endpoints）：如 routers 只负责转发数据，而不关心数据的内容。</li>
<li><strong>端到端准则</strong>（end-to-end principle）：在 end hosts 上实现功能，而不是 routers 上。</li>
<li><strong>分层设计</strong>（layering）：即上下层之间的依靠关系。</li>
<li><strong>Federation via narrow-waist interface</strong>：所有用户都使用相同的 layer 3 协议（IP）</li>
</ol>
<p><img src="./_images/federation-narrow-waist.png" alt="如图，第 3 层只使用 IP 协议，形成了一个狭窄的腰部接口" /></p>
<p><strong>Demultiplexing</strong>：由于第 3 层需要决定每一个层的下一个层使用什么协议，所以在 header 中需要包含下一层协议类型的信息</p>
<p><img src="./_images/demultiplexing-layer-3.png" alt="以第 3 层为例，需要决定下一层是使用 TCP 协议还是 UDP 协议" /></p>
<p>而在第 4 层中，需要包含一个应用运行端口的信息，以将数据传递给正确的程序。操作系统会自动根据端口号确定使用什么协议，如 80 端口通常是 HTTP 协议，443 端口通常是 HTTPS 协议。一个 private 的客户端的端口可以是随机的，但是 public 的服务器端口需要是固定的。</p>
<p><img src="./_images/demultiplexing-layer-4.png" alt="第 4 层需要包含端口信息" /></p>
<ul>
<li>Layers 1 and 2 are implemented in hardware, on the network interface card (NIC).</li>
<li>Layers 3 and 4 are implemented in software, in the operating system.</li>
<li>Layer 7 is the applications running in software.</li>
</ul>
<p><img src="./_images/1-38-layers-in-os1.png" alt="不同层由不同的部件实现" /></p>
<p><strong>End-to-End Principle</strong>：一些应用的 feature 只能在 end hosts 上实现，如网络只需要在 end hosts 上检查传输是否成功，而不需要在每一个 router 上检查。但是这个准则是可以被打破的。</p>
<h2>资源共享</h2>
<p>网络资源往往会在多对 end hosts 之间共享（如同时传递数据时，可能会经过相同的 routers 和 links），实际中是采用的 <strong>statistical multiplexing</strong>（多路复用） 的方式来共享资源。</p>
<p><img src="./_images/statical-multiplexing.png" alt="如右图，通过用户需求，动态分配资源。左图为静态（预先）分配资源，会产生资源浪费。" /></p>
<p>实现 statistical multiplexing 的方式：</p>
<ol>
<li><strong>circuit switching</strong>：在传输数据之前，先建立一个连接（circuit），并在每一个 router 和 link 上分配固定的资源（reservation），直到传输结束再传输一次结束信号释放占用的资源。</li>
<li><strong>packet switching</strong>：将数据分成多个数据包（packet），为每个 packet 独立分配资源（即不同 flow 的 packets 和同一个 flow 中的 packets 都被视作是独立的），然后按照 best-effort 的方式传输。</li>
</ol>
<p>对于 bursty 的传输，packet switching 更加高效，因为它可以动态分配资源，而 circuit switching 则需要预先分配资源，可能会导致资源浪费（如浏览网页）。而 smooth 的传输（如视频流）则更适合 circuit switching，因为它可以保证传输的稳定性。</p>
<p>容错性方面，packet switching 更加健壮，因为它可以在某个 router 或 link 失败时重新传输，而 circuit switching 则需要重新建立连接。</p>
<p>优势对比：</p>
<ul>
<li><strong>Circuit switching</strong> pros:
<ul>
<li>Reservations give applications better performance.</li>
<li>Reservations are more predictable and understandable.</li>
</ul>
</li>
<li><strong>Packet switching</strong> pros:
<ul>
<li>More efficient.</li>
<li>Faster startup to first packet delivered.</li>
<li>Easier recovery from failure.</li>
<li>Simpler implementation.</li>
</ul>
</li>
</ul>
<p>现代网络通常使用 packet switching，而在一些受限的场景中使用 circuit switching。</p>
<h2>链路层</h2>
<ul>
<li><strong>bandwidth</strong>：单位 bit/s</li>
<li><strong>propagation delay</strong>：在 link 中的传播延迟</li>
</ul>
<p><img src="./_images/link-time-diagram.png" alt="当带宽为 1Mbps，传播延迟为 0.001s 时的传送时序图" /></p>
<ul>
<li><strong>packet delay</strong> = propagation delay + transmission delay</li>
<li><strong>transmission delay</strong> = packet size / bandwidth</li>
</ul>
<p>对于较小的 packet，transmission delay 可以忽略不计（因为把数据放到链路上不需要花很多时间），propagation delay 占主。即小带宽，低延迟的 link 更好。反之，对于较大的 packet，transmission delay 占主，propagation delay 可以忽略不计。即大带宽，高延迟的 link 更好。</p>
<p>可以通过管道图来直观的理解：当 packet size 较小时，bandwidth 没法打满，造成浪费，不如优化 propogation delay；反之同理。</p>
<p><img src="./_images/pipe1.png" alt="每个小矩形中为 1s 内传输的 packet 数量" /></p>
<p>上面的分析都没有考虑排队（queueing）的情况。排队分为瞬态过载（transient overload）、持续过载（persistent overload）。</p>
<p><img src="./_images/transient-overload.png" alt="transient overload" /></p>
<p><img src="./_images/persistent-overload.png" alt="persistent overload" /></p>
]]></content>
        <author>
            <name>Kinnari</name>
            <uri>https://kinnari-blog.vercel.app/</uri>
        </author>
        <published>2025-06-21T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[献给我的 20 岁]]></title>
        <id>https://kinnari-blog.vercel.app/posts/20-year-old-rethinking/</id>
        <link href="https://kinnari-blog.vercel.app/posts/20-year-old-rethinking/"/>
        <updated>2025-04-13T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Keep hands dirty, and keep mind clear. 人生能有几个 20 岁呢？寥寥三四个吧。我想在这个重要的节点...]]></summary>
        <content type="html"><![CDATA[<blockquote>
<p>Keep hands dirty, and keep mind clear.</p>
</blockquote>
<p>人生能有几个 20 岁呢？寥寥三四个吧。我想在这个重要的节点，记录下我的一些思考，算是对过往的总结和反思？一些想法或许会很幼稚，甚至说错误，还望见谅，当然更希望你能指出来，我们共同进步。</p>
<p>祝自己生日快乐。</p>
<h2>学深与学广</h2>
<p>一年前，我总是会有这样的疑问：我究竟应该是学深，还是学广呢？学深的话，虽然能够很快地得到某个领域的前沿知识，但是万一哪天这个领域不行了呢？到时候的退路在哪里呢？学广的话，虽然看上去像是样样都精通了，但是没有深入，也不会有较高的成就。</p>
<p>在我没有进入实验室之前，我基本上是按照广度在进行学习：我按照 csdiy 上的公开课，以及自己找的一些公开课、博客开始学习，先后接触了数学分析、线代、概统、随机过程、凸优化，以及之后的机器学习、深度学习、cv、nlp、slp、AIGC、大模型等等。我当时是这样感觉的：哇，我学了好多新东西欸。但是也会有隐隐的担忧：我学了这些，对于之后的科研/实习等等的帮助会有多大呢？</p>
<p>之后进入实验室，正式开始科研学习，迅速地深入一个领域之后，我突然感觉到，我之前学习的这么多课，似乎并没有特别大的帮助（似乎就 linux、conda、docker、pytorch 有点帮助）；或许从一开始，我就应该直接开始学科研需要用到的东西，这样或许会更快，或许也会更容易出成果。于是在之后的大半年时间里，我基本上没有再碰公开课这一类增加广度的资料，而是一头扎在了前沿的 paper、项目中（当然，敝人不才，没能做出什么有影响力的工作）。</p>
<p>然而，当我有一天开始思考未来的发展方向的时候，却发现这样做未免太过激进——我没有给自己留下充足的后路，倘若等到我就业的时候，我研究的东西已经凉凉了，应该怎么办？我还有其他路径可以走吗？换句话说，这个社会的容错率真的容纳得下现在这样的我吗？想必是不能的。</p>
<p>那么，学深和学广，如何抉择？</p>
<p>而后来我读到了这样一篇<a href="https://logikosto.feishu.cn/wiki/BFr8w8KByiI98akpvgRcx1RYnob?fromScene=spaceOverview">教程</a>，它指出的几点让我茅塞顿开：</p>
<ul>
<li>学习的本质——体系化学习是一种错觉，学习不等于记忆；学习思维</li>
<li>工作与学习——局部与整体，短期与长期，目的性与工具性（判断力）</li>
</ul>
<p>它里面所讲的工作与学习的关系，不正就是我所不得解的学深与学广的关系吗？我似乎可以把学习视作一个金字塔形状的结构：底层是代表了广度，而顶部则代表了高度。受限于社会形式，我们不得不去在底层未能牢实的基础上就开始深层次的探索；然而这不应该是我们放弃广度学习的理由——把路走宽永远是对的，深入学习（不管是现在正在进行的，还是未来将会发生的）一定会依赖于牢实的基础，which 需要通过广度的学习来补足；为了未来（可能的）解决一个更高纬度、更有意义的问题的时候，无论是广度，还是深度，都是不可或缺的一部分（这方面的例子可以看看莎莎写的<a href="https://www.zhihu.com/question/471105298/answer/1995471916">如何看待2022年秋招Java后端开发岗一片红海？</a>以及<a href="https://zhuanlan.zhihu.com/p/84927997">半条命</a>）。</p>
<p>关于这个话题更多的思辨，前人之述已备，我就不献丑了。不过可以大概讲讲我现在的想法：我准备找到一个方向深耕的同时，利用本科剩下的时光，努力打牢各个方面的基础，以备（研究生）将要毕业时能够毫不费力地追逐那时候的热点。</p>
<h2>一个有意思的比喻</h2>
<p>我其实一直认为人类就是一个进化了数百万年的某种模型 + <strong>RL</strong> 机制，不管是从训练婴幼儿说话，还是到上课、学会某项技能，实质上都是对于自己的反复训练，只不过这个过程相比于如今的 LLM 之辈，“效率”高了不知多少。</p>
<p>我向来都将知识分为三种：一种是自己内化了的知识，另一种是从外界汲取了但是尚未内化的知识，最后一种是外界的、我尚未汲取的知识。更新它们在我看来和 LLM 的 forward 以及 backward 有异曲同工之妙。</p>
<p>当我从外界获取知识时（听课、读论文、读博客、与人讨论），我将它比作 <strong>forward</strong>，在此期间我从书上获得了“激活值”，并进行了恰当的组织与重构（简单记录笔记）；当我对获取到的知识进行思考时，我是在进行 <strong>backward</strong>，获取到的“梯度”将会用于更新我内化了的知识；最后，我会将这些思考进行记录，舍弃一些或是修改一些（梯度剪裁），同时更新内化知识。</p>
<p>这个过程看似很美好，实则存在一些显而易见的问题：</p>
<ul>
<li>不同于 LLM，我们其实并不能保证在每次 forward 之后立即开始 backward，期间可能会受到各种各样的影响，导致我们不得不暂停当前的事情</li>
<li>不同于 LLM，我们的“权重”并不能永久的保存以及随时的取出，也就是说我们所内化的知识，会有遗忘、暂时找不到的情况发生</li>
<li>不同于 LLM，我们的“模型”其实并没有固定的输入和结构，意味着我们得接受各式各样的信息，每次更新权重都需要主动寻找哪些部分需要被更新，以及学习率等超参数如何选择</li>
<li>不同于 LLM，我们还可以借助非常多的工具来帮我们“训练”</li>
</ul>
<p>这时候，写一点简单的笔记、写一些自己的思考的重要性就得以体现了：它们就是 <strong>checkpoints</strong>！当结束当前的进展时，我们需要记录当前的状态，以避免下次重新的训练（recompute）；为了下一次能够迅速的将几个可能的环节串联到一起，就需要做好 <strong>rag</strong>，即做好一个索引状态并存储下来，方便下次直接检索。另外，显而易见的，我们需要反复几次地进行记忆重现（replay），以避免遗忘。</p>
<p>当然，我们在同一时间也会遇到 <strong>multi task</strong> 的问题，而这，正需要 <strong>scheduler</strong>：对于若干个 requests，如何合理分配 running、waiting、swap 等状态的 requests。对于每一个 request，也会有相对应的 <strong>kv cache</strong>，当 request 过多时，如何合理的安排 kv cache 的调度方式，则需要每个人为自己思考了。</p>
<h2>方向选择</h2>
<p>目前为止，我（只能说是）接触了许多领域（而非深入研究），例如说 TTS、多模态、分布式推理等等，也遇见了并和许多人交流了挺多，虽然没能有什么值得一提的产出，但是关于方向选择，关于怎么向前去迈出步子，我想我也有了一定的体会。</p>
<p>首先我想说的是，<strong>多尝试，多涉足</strong>。以算法为例，在这块刚刚起步的人其实是没有什么顾虑的，他们只需要扎扎实实把手上的机器学习、深度学习等等课程学会就好了，而不需要考虑以后要去做大模型，3D，多模态，语音，agent，mlsys 还是其他什么。而到了要考虑后者的时候，未免就会陷入一些迷茫：我到底该去做哪个？是考虑以后工作更多一点，还是学界更多一点，或者是自己的兴趣爱好多一点？</p>
<p>如果我能重来一遍，我想我大概会去各个方向 roll 一圈吧，各抽一两周或者至多一个月来了解它们在做什么、有什么前沿进展，或者说日后的工业、学界的发展前景等等，而非仓促地加入某一个实验室（打工）。另外，也不能局限于本校，更应该到处看看，比方说外校、企业、研究院等等，因为一来本校不一定有那么多可供你选择的方向，二来校内资源可能也会比较有限。</p>
<p>可能你会说，我什么都不会，怎么去实习呢？人家公司/实验室怎么会要自己这种小菜鸟呢？然而，就我所观察到的，说出这种话的同学往往具备以下几个特点：</p>
<ul>
<li>没有足够流通的信息，认识不到真实的 bar 有多高</li>
<li>不会自己针对性的进行准备，比方说做做一些小的项目，至少能展示自己的自学、实操能力，也可以写到简历上丰富内容</li>
<li>随大流，没有自己的想法，就跟着学校的安排温水煮青蛙了</li>
</ul>
<p>但其实，你至少尝试过就会知道，并没有想象中的那么困难，并且实习也不等同于上手工作，（大多数）也是会有一个学习的过程的。实习的意义不是为了现在就工作赚钱/发 paper，而是为了理解业务、学习技术，为将来进大厂/读研究生/出国做准备。</p>
<p>好吧似乎扯远了，我们回到方向选择的正题上来。我有过一个大（而无用？）的想法：人活着不能只是为了几台机器吧（笑），是不是应该做一些更有意义的事情呢。除了在现在的社会中被迫的内卷，被各种指标量化，人类难道不应该仰望星空？难道不应该有自己的，更宏大的追求？不过为了追求这种，也必须要之前做好一定的经济物质基础，就好像人起跳前，必定需要下蹲一样。</p>
<p>Anyway，有时候，想要向前一步，意味着必须舍弃一些东西；而有些东西是骨子里的，注定放不下。我不希望日后为了其他什么的放下我内心深处真正想要的东西，永远都不要。</p>
<h2>研究</h2>
<p>关于做研究，我想目前为止只能对自己问出下面的三个问题：</p>
<ol>
<li>你所在领域最重要的问题是什么？</li>
<li>你正在解决其中之一吗？</li>
<li>如果不是，为什么？</li>
</ol>
<p>而之后，我应该能逐渐回答他们吧。</p>
<h2>输出</h2>
<p>我希望自己的想法能够被记录，被别人看到，希望有一天有人可以和我一起讨论这些想法，希望能够得到交流和共鸣。所以，我想要向外输出，原因就是这样简单。</p>
<p>我也知道自己的很多想法很幼稚，很蠢，但我也不害怕被人嘲笑，被人讽刺，被人一笑而过。有则改之，无则加勉，就这样很足够了——笑我的人得到了笑的满足，而我得到了更进一步的可能性。</p>
<p>我阅读过 Markson、OneChapter、Emiya、Marisa、星爷等等人的自述，他们的喜悦、疲惫、放松、迷茫，我也都感受过，因而我更能理解这份通过不管是文字还是视频传递的奇妙感受——那时候我们仿佛是一个人，他们的种种经历似乎也投射到我的生活中来，而又逐渐和自己的过往一点点重叠。我曾听说所有人是一张网的关系，而我认为网上的每个节点并非一个个单独的人，而是一群相似的人的集合体。而输出，就是让这样一群相似的人能够有机会，最终走到一起。</p>
<p>也正是通过这些，认识了好多人，不管是 abc 群、后来自己创建的小群聊、水源，还是别的什么地方，真是何其有幸。</p>
<h2>任务管理</h2>
<p>再来说说任务管理吧。管理任务，其实来源是因为时间是有限的，<s>而任务是做不完的</s>，那么时间哪里来呢？当然每个人每天的时间都只有 24 小时啦，根据我的经验，大抵有以下几点：</p>
<ul>
<li>早上 8 点开始工作，你会发现任务可以很早地完成掉。比方说你原本是 9 点开始工作，但是你提前一个小时（于你而言并不是什么办不到的事情，不要为自己找借口），你的所有工作都会提前一个小时完成，进而大大增加收获感和满足感，从而提高一整天的效率，多出来的时间不管干什么都是开心且没有负担的</li>
<li>学会休息。人不是机器，人的长时间集中注意力思考是有限的，因此你的效率会在一段时间后开始大幅度地下跌。最简单的办法就是用番茄钟——做一段时间休息一下，另外进入心流也很重要。效率高 = 时间多了。</li>
<li>不要死磕在一些任务上。就比如，一些 bug，你盯死了都盯不出来，那这个时候就不要和它耗太久，放在一边，去做其他事情；在空闲的时间里，你的脑海里会反复出现这个 bug，进而更有可能达到灵光一闪的效果</li>
<li>去运动，运动带来的精力旺盛，才能够有更高的效率</li>
</ul>
<p>另外，最好是不要养成拖延症的习惯，这样并不好。但是关于拖延症也有如下一些观点需要注意：</p>
<ul>
<li>为了做真正的工作而把琐事搁在一边不算拖延
<ul>
<li>“琐事在扼杀伟大项目方面如此有效，以至于许多人故意用它来达到这个目的”</li>
<li>不可放任琐事占据一整天，来逃避面对某些棘手的问题</li>
</ul>
</li>
<li>大块的时间和恰当的心境是可遇而不可求的，每当遇到这样的时候，都请拿来做最为重要的事情</li>
</ul>
<p>以前写过一篇关于流式规划的，现在想来过于幼稚。如今我一般是这样进行任务安排的：</p>
<ol>
<li>把所有任务都记录在一起，写下开始和截止日期；obsidian 的 tasks 插件可以自动根据这些信息来计算重要性</li>
<li>每天早上起来给自己指派几个最重要任务，并首先保证能够完成这些任务</li>
<li>这一天的时间内有新的任务，就继续安排下来，如果不是非常紧急，可以不用强求当天完成。</li>
<li>完成后或晚上的时候复盘，达成动态的任务调度</li>
</ol>
<p>最后引用一段不知道从哪里看到的话，希望自己不要变成一个工作狂：</p>
<blockquote>
<p>现在才明白，吃喝玩乐并不等于虚度光阴，吃苦耐劳也不等于意义非凡。当你焦虑、精神内耗的时候，请一定要记得，人生是各种各样体验的叠加。只要你想，你就可以去做那些看似毫无意义的事情，比如说发呆、看日出、数星星、坐在草地上一整天…你的体验就是意义。人生不一定要去做大家世俗认为有意义的事情才对，也不是非得按照社会主流价值观去生活，比如，学生一定要上名牌大学，上了大学就一定要读研、读了研就一定要去大厂卷生卷死。这些都是别人给的定义，是否执行只看我们自己。谁都是第一次来人世间，意义是我们自己赋予的，人生是旷野不是轨道，只要你当下是享受的，那么这个活动就是有意义的，那人生就是有意义的</p>
</blockquote>
<h2>认识自己</h2>
<p>我以和外界交互的方式，认识我自己。我和他人交流，然后观察自己的反应；我阅读这个世界，然后留意内心的想法。我感到向内探索自己实在困难，于是向外求索。</p>
<p>所以我自己是什么呢？我是不是一个平庸的人（时常感觉到自己很平庸）？或者说我自己到底是怎么样的一个人？</p>
<p>我想到目前，我还不能说非常清晰的认识自己，往后也会努力向这方面发展吧。</p>
<h2>最后</h2>
<p>再次祝自己生日快乐🥳</p>
]]></content>
        <author>
            <name>Kinnari</name>
            <uri>https://kinnari-blog.vercel.app/</uri>
        </author>
        <published>2025-04-13T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[北京游]]></title>
        <id>https://kinnari-blog.vercel.app/posts/beijing-trip-2025/</id>
        <link href="https://kinnari-blog.vercel.app/posts/beijing-trip-2025/"/>
        <updated>2025-04-13T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[感谢致远给了一个公费旅游参观 top2 和 msra 的机会。简单写写经历吧 先是吃了个烧烤，味道非常不错，不过忘了拍吃的，就传一个合照吧...]]></summary>
        <content type="html"><![CDATA[<p>感谢致远给了一个<s>公费旅游</s>参观 top2 和 msra 的机会。简单写写经历吧</p>
<h2>10 号晚上</h2>
<p>先是吃了个烧烤，味道非常不错，不过忘了拍吃的，就传一个合照吧</p>
<p><img src="_images/beijingheying.jpeg" alt="高中同学 + 一个超厉害的网友" /></p>
<p>真是人才济济啊（除了我），往后也要经常聚餐才好，这样的机会不知还能有多少次呢。</p>
<hr />
<p>吃完饭之后，去了北大校园参观，和几个同学一块聊了聊天，感觉还挺不错，因为是晚上，所以就没拍多少照片，补一张第二天拍的吧</p>
<p><img src="_images/weiminghu.jpeg" alt="未名湖" /></p>
<h2>11 号上午</h2>
<p>先是去了北京 MSRA，茶歇、办公区域、真的棒极了，简直是梦中情司</p>
<p><img src="_images/msra.jpeg" alt="MSRA" /></p>
<p>负责人介绍了一下 hackson 企业文化，还有 center1 的成果展示（但是有一说一，展示的东西都很普通，没有看到非常先进的技术……也算是一点小遗憾了）</p>
<p><img src="_images/msra_hackson.jpeg" alt="MSRA Hackathon" /></p>
<p>在实习区域外面合了一个影，这也是人才济济了（除了我）</p>
<p><img src="_images/msraheying.JPG" alt="一群致远爷（bushi）" /></p>
<h2>11 号下午</h2>
<p>在北大闲逛，和一个清华的同学边走边聊，真令人放松，然后是水水的交流了一下，去某一个讲座蹭了个饭，还遇上了另一个北大同学，也算是太巧了</p>
<p><img src="_images/beidatushuguan.jpeg" alt="北大图书馆" /></p>
<h2>11 号晚上</h2>
<p>去清华转了转，原本说要刮大风，但是幸好没有，参观了下梦中情校，内心感叹其实挺多的。</p>
<table>
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td><img src="_images/qinghuayuan.jpeg" alt="" /></td>
<td><img src="_images/thu.jpeg" alt="" /></td>
</tr>
</tbody>
</table>
<p>然后吃了吃桃李园的烤冷面，真不错啊，果真同以前听人说的那样好吃。真的很希望以后能多吃到几次，可能，因为一些不方便说的原因，成了我心里的一个结了吧。</p>
<p><img src="_images/taoliyuankaolengmian.jpeg" alt="桃李园烤冷面" /></p>
<h2>12 号</h2>
<p>返程，因为大风的原因，原本去通院的计划被取消了。</p>
<p>希望以后还能常来，常来参观清北校园，常来吃吃清华的烤冷面。</p>
]]></content>
        <author>
            <name>Kinnari</name>
            <uri>https://kinnari-blog.vercel.app/</uri>
        </author>
        <published>2025-04-13T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[我的 Ubuntu24.04]]></title>
        <id>https://kinnari-blog.vercel.app/posts/ubuntu-setup/</id>
        <link href="https://kinnari-blog.vercel.app/posts/ubuntu-setup/"/>
        <updated>2025-03-16T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[社交软件：QQ、微信：这俩都用 flatpak 安装，防止扫硬盘 Telegram：用 appimage，用于一些国外的群聊 浏览器：Go...]]></summary>
        <content type="html"><![CDATA[<h2>软件</h2>
<ol>
<li>社交软件：
<ol>
<li>QQ、微信：这俩都用 flatpak 安装，防止扫硬盘</li>
<li>Telegram：用 appimage，用于一些国外的群聊</li>
</ol>
</li>
<li>浏览器：
<ol>
<li>Google Chrome 和 Edge：因为 Chrome 在学校的一些网页上会有问题，Edge 就用来应急</li>
</ol>
</li>
<li>办公、科研：
<ol>
<li>飞书：官网有 deb 包，在 ipads 实习的时候用过</li>
<li>evolution：收发邮件，用 apt 装</li>
<li>腾讯会议：只能在 x11 下使用，官网有 deb 包</li>
<li>WPS Linux：注意不要装错成 WPS2019 了，官网在 <a href="https://linux.wps.cn/">https://linux.wps.cn/</a></li>
<li>Zotero：用来管理论文，不过目前为止还没完全熟练</li>
<li>Zoom：用来和国外教授开会</li>
<li>Cohesion：notion 的第三方客户端，用来记录科研进展；也可以用 obsidian 代替，但是多人协作更方便些</li>
<li>xmind、drawio：论文画图、思维导图</li>
</ol>
</li>
<li>代码：
<ol>
<li>vscode：远程主力，以及写 jupyter notebook 的时候用</li>
<li>neovim：本地写小项目、改配置用，配置在 GitHub 上；</li>
<li>neovide：偶尔用 neovide 当 neovim 的 GUI，用 AppImage 装，因为</li>
<li>JetBrains 系列：写大型项目用，但是基本没打开过，可以装一个 JetBrains Toolbox 来集中管理不同的 IDE</li>
<li>Cursor：白嫖版的，用来读大型项目</li>
<li>vim：用来改需要 root 权限的文件，用 apt 装，配置在 GitHub 上</li>
</ol>
</li>
<li>终端：
<ol>
<li>Wezterm：比原生的 gnome-terminal 重一点，但是毕竟好看些（</li>
<li>zsh + oh-my-zsh：非常好看好用好吧</li>
<li>tmux：分屏操作</li>
</ol>
</li>
<li>笔记：
<ol>
<li>Obsidian：用 markdown 记笔记，目前是在用 Onedrive 进行笔记同步，用 GitHub 进行配置保存；用 deb 包可以安装（见 <a href="https://github.com/obsidianmd/obsidian-releases">GitHub - obsidianmd/obsidian-releases: Community plugins list, theme list, and releases of Obsidian.</a>）</li>
<li>Neovim：偶尔用 neovim 写写笔记，用官网装最新版的</li>
</ol>
</li>
<li>娱乐：
<ol>
<li>Bilibili：用的第三方客户端 <a href="https://github.com/msojocs/bilibili-linux">GitHub - msojocs/bilibili-linux: 基于哔哩哔哩官方客户端移植的Linux版本 支持漫游</a></li>
<li>音乐：
<ol>
<li>Yesplaymusic：网易云音乐的第三方客户端，非常纯净，在 GitHub 上装 <a href="https://github.com/qier222/YesPlayMusic">GitHub - qier222/YesPlayMusic: 高颜值的第三方网易云播放器，支持 Windows / macOS / Linux</a></li>
<li>Amberol：用来放本地音乐，用 apt 装</li>
<li>QQmusic：用 flatpak 装，听网易云没有版权的歌</li>
</ol>
</li>
</ol>
</li>
<li>阅读：
<ol>
<li>Koodo Reader：用来读 EPUB 格式的电子书</li>
<li>Newsflash：RSS 阅读器，用来订阅一些大佬的博客，用 flatpak 装</li>
</ol>
</li>
<li>系统：
<ol>
<li>Flatseal：用来管理 flatpak 装的软件，权限设置等，用 flatpak 装</li>
<li>Font downloader：用来便捷的装字体，但是没怎么用过，用 apt 装</li>
<li>Clash Verge：vpn，有点小 bug 但是能将就用</li>
<li>Fcitx5 + 雾凇输入法：系统自带的 ibus 无法在腾讯会议和微信中输入中文，所以换了这个组合</li>
<li>BleachBit、Czkawka：用来清理垃圾文件</li>
<li>Gparted：磁盘管理</li>
<li>Software：需要把原来的软件商店给卸载了（因为是 snap），然后重新装 gnome-software</li>
<li>flatpak：装一些 apt 里面没有的软件，以及用来隔离一些流氓的国产软件</li>
<li>Tweaks：用来自定义系统</li>
<li>tlp：更好的电源管理</li>
</ol>
</li>
<li>文件管理：
<ol>
<li>nautilus：系统自带的文件管理器</li>
<li>meld：nautilus 的一个插件，可以显示 Git 仓库的状态</li>
<li>lazygit：终端 git 的 GUI，一般就用来看看仓库状态</li>
<li>yazi：终端文件管理器，很方便，官方有下载指南 <a href="https://yazi-rs.github.io/docs/installation/">Installation | Yazi</a>，配置保存在 github 上，用了三个插件 git、mediainfo、miller</li>
<li>文件查看器：
<ol>
<li>Image Viewer：系统自带的图片查看器</li>
<li>mpv：查看视频</li>
</ol>
</li>
<li>Onedrive：用来同步文件，配合 onedrive-tray 使用，并且设置为开机自启</li>
</ol>
</li>
<li>效率：
<ol>
<li>Pomorodo：番茄钟，用 apt 装</li>
<li>Dialect：小翻译软件，实质上调用的是 google 翻译，配上快捷键之后很方便，用 apt 装</li>
<li>Kuro：微软 todo 的第三方客户端，在 GitHub 上下载</li>
<li>Characters：系统自带，有很多表情包，便于查找</li>
<li>Picgo：图床管理</li>
</ol>
</li>
<li>其他工具：
<ol>
<li>obs studio：用来录屏</li>
<li>docker：快速配置环境</li>
<li>conda：配置 python 环境</li>
<li>mkdocs：用来搭网站</li>
</ol>
</li>
</ol>
<h2>Gnome 美化</h2>
<ol>
<li>装 gnome-tweaks 用于更多的自定义，以及 extensions 用来管理插件</li>
<li>字体：
<ol>
<li>Interface/document text 用 Noto Sans CJK SC</li>
<li>monospace text/终端/vscode 用 MesloLGS Nerd Font Mono</li>
<li>Chrome 和个人网站 用 LXGW</li>
</ol>
</li>
<li>外观：
<ol>
<li>Cursor：WhiteSur-cursors</li>
<li>Icons、Shell、Legacy Applications：WhiteSur-Dark</li>
</ol>
</li>
<li>插件：
<ol>
<li>Blur my shell：用于模糊效果</li>
<li>Caffine：全屏不熄屏</li>
<li>Clipboard Indicator：剪切板</li>
<li>Coverflow-tab：更好的切换页面</li>
<li>Hide items：隐藏 bar 上过多的软件图标</li>
<li>input method panel：给 fcitx5 更好看的输入面板</li>
<li>User themes：自定义主题</li>
<li>Apps menu</li>
<li>Removable drive menu</li>
<li>Ubuntu appindicators</li>
<li>Ubuntu dock：仿 mac 风格</li>
<li>Ubuntu Tilling assistant</li>
</ol>
</li>
<li>壁纸：到处搜集的，隔几天换一下</li>
</ol>
<p><img src="https://cdn.jsdelivr.net/gh/KinnariyaMamaTanha/Images@images/20250316203417556.png" alt="" /></p>
<p><img src="https://cdn.jsdelivr.net/gh/KinnariyaMamaTanha/Images@images/20250316203452502.png" alt="配得有点像 mac 哈" /></p>
]]></content>
        <author>
            <name>Kinnari</name>
            <uri>https://kinnari-blog.vercel.app/</uri>
        </author>
        <published>2025-03-16T00:00:00.000Z</published>
    </entry>
</feed>