<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <author>
    <name>YFGUG</name>
  </author>
  <generator uri="https://hexo.io/">Hexo</generator>
  <id>https://luanjiahao.qzz.io/</id>
  <link href="https://luanjiahao.qzz.io/" rel="alternate"/>
  <link href="https://luanjiahao.qzz.io/atom.xml" rel="self"/>
  <rights>All rights reserved 2026, YFGUG</rights>
  <subtitle>记录每一次出发</subtitle>
  <title>旅行与生活</title>
  <updated>2026-08-28T11:20:48.104Z</updated>
  <entry>
    <author>
      <name>YFGUG</name>
    </author>
    <category term="项目" scheme="https://luanjiahao.qzz.io/categories/%E9%A1%B9%E7%9B%AE/"/>
    <category term="海光 DCU" scheme="https://luanjiahao.qzz.io/tags/%E6%B5%B7%E5%85%89-DCU/"/>
    <category term="vLLM" scheme="https://luanjiahao.qzz.io/tags/vLLM/"/>
    <category term="Attention" scheme="https://luanjiahao.qzz.io/tags/Attention/"/>
    <category term="KV Cache" scheme="https://luanjiahao.qzz.io/tags/KV-Cache/"/>
    <category term="长上下文" scheme="https://luanjiahao.qzz.io/tags/%E9%95%BF%E4%B8%8A%E4%B8%8B%E6%96%87/"/>
    <content>
      <![CDATA[<h2 id="摘要"><a href="#摘要" class="headerlink" title="摘要"></a>摘要</h2><p>在大模型推理中，Attention 的公式通常不是最难的部分，真正决定性能的往往是数据如何落在显存里，以及硬件一次能够以什么粒度把这些数据取出来。</p><p>本文以 Qwen3.5 的官方 prefix-off 长上下文路径为例，讨论一个看似很小、实际上贯穿 vLLM 配置、KV Cache、paged attention 和 DCU kernel dispatch 的问题：该路径的默认物理 KV Cache page 是 784 tokens，而海光 DCU gfx936 上的 vendor paged-attention 路径要求 page 长度满足 64-token 对齐。最终我们没有把 page size 当作 scheduler 参数硬编码，而是沿 vLLM 原有的混合 Attention&#x2F;Mamba 页面计算逻辑，把它自动推导到最小兼容值 832。</p><p>这个改动本身只有一个数字级别的差异，但它改变了 kernel 能否被调用的前提。随后，q&#x3D;4096 的 chunked-prefill 进入 vendor FlashAttention 路径，q&#x3D;1 decode 继续使用 page-contiguous Triton 路径，避免了把两个完全不同的工作负载混成一个 kernel。</p><p>本文重点放在 DCU 的执行粒度、KV Cache 的物理布局、block table 的地址计算和端到端验证，不展开比赛提交、仓库地址或内部环境细节。</p><hr><h2 id="1-为什么要从“页”开始看-Attention"><a href="#1-为什么要从“页”开始看-Attention" class="headerlink" title="1. 为什么要从“页”开始看 Attention"></a>1. 为什么要从“页”开始看 Attention</h2><p>对一个长度为 <code>T</code> 的上下文，Attention 需要访问历史 K&#x2F;V。vLLM 不会为每个请求分配一整块连续的 K&#x2F;V，而是把缓存切成固定大小的物理页，并通过 block table 把逻辑页映射到物理页。</p><p>可以把一个 token 的地址简化成：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">logical_page = token_index // BLOCK_SIZE</span><br><span class="line">page_offset  = token_index %  BLOCK_SIZE</span><br><span class="line">physical_page = block_table[logical_page]</span><br></pre></td></tr></table></figure><p>然后再结合 K&#x2F;V cache 的 stride、KV head 和 head dimension 计算最终地址。</p><p>这种分页设计解决了显存碎片和动态请求管理问题，但也带来了一个硬件事实：<strong>BLOCK_SIZE 不只是调度器里的整数，它同时决定了 kernel 内部的页内循环、向量化访问和边界 mask。</strong></p><p>如果硬件 kernel 希望以 64 tokens 为一个规整的访问单元，那么一个不能被 64 整除的物理页，通常会在每个 page 的尾部留下不规整的 tile 或额外的边界处理。对于短请求，这可能只是少量浪费；对于长上下文，页表解析和 K&#x2F;V 读取会被重复很多次。</p><h2 id="2-gfx936-的关键约束：wave64-与-64-token-访问粒度"><a href="#2-gfx936-的关键约束：wave64-与-64-token-访问粒度" class="headerlink" title="2. gfx936 的关键约束：wave64 与 64-token 访问粒度"></a>2. gfx936 的关键约束：wave64 与 64-token 访问粒度</h2><p>海光 DCU 的 ROCm 兼容执行环境在 gfx936 路径上采用 wave64 执行粒度。这里不应把 wave64 简化成“线程数变成 64”这么一句话；它会影响：</p><ul><li>一个 wave 内线程如何协同加载向量；</li><li>K&#x2F;V 的 token 维度如何分配给 lane；</li><li>连续地址能否形成规整的 memory transaction；</li><li>kernel 对页尾部 mask 和跨页访问的处理方式。</li></ul><p>在本实验使用的 vendor paged-attention 接口中，物理 page 长度需要满足 64-token 对齐。直接由源码和运行时日志确认的是这个接口约束；它与 wave64 的访问粒度相匹配，但本文不把 64 对齐唯一归因于 wave64。默认的 784 可以拆成：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">784 = 12 × 64 + 16</span><br></pre></td></tr></table></figure><p>而 832 可以拆成：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">832 = 13 × 64</span><br></pre></td></tr></table></figure><p>这意味着 page832 内部可以由 13 个完整的 64-token 访问组组成，不需要在每个物理页末尾留下一个 16-token 的非规整尾块。这里的收益不是来自减少 Attention 数学计算，而是来自让数据布局与硬件执行粒度一致。</p><p>需要强调的是，page832 不是“越大越快”的经验参数。1024 也满足 64 对齐，但它比 784 多出约 30.6% 的 token 容量；832 只增加约 6.1%，是满足该约束的最小兼容页，更适合显存紧张的 27B 混合模型。</p><h2 id="3-Qwen3-5-的混合缓存为什么让这个问题更复杂"><a href="#3-Qwen3-5-的混合缓存为什么让这个问题更复杂" class="headerlink" title="3. Qwen3.5 的混合缓存为什么让这个问题更复杂"></a>3. Qwen3.5 的混合缓存为什么让这个问题更复杂</h2><p>Qwen3.5 同时包含 full attention 和 GDN&#x2F;Mamba 类状态路径。vLLM 需要让 Attention page 和 Mamba state page 在混合模型中保持兼容，否则同一个请求在不同层之间会遇到不同的缓存粒度和 padding 规则。</p><p>在 prefix caching 关闭的路径上，页面选择可以抽象为（省略了现有配置中的最小值比较和后续 padding 分支）：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">attn_page_size_1_token = FullAttentionSpec(...).page_size_bytes</span><br><span class="line">required_page = ceil(mamba_page_bytes /</span><br><span class="line">                     (alignment_tokens * attn_page_size_1_token))</span><br><span class="line">                 * alignment_tokens</span><br></pre></td></tr></table></figure><p>对于这个 Qwen3.5&#x2F;DCU 形状，backend 适配把 <code>alignment_tokens</code> 从通用的 16 提升到 64；沿原有的 <code>verify_and_update_config</code> 逻辑计算后，默认页从 784 向上取整到 832。</p><p>这和直接在 scheduler 中写一句 <code>block_size = 832</code> 是两件事：</p><ol><li>计算仍然由现有的模型配置和缓存规格数据流完成；</li><li>页面大小仍然作为 KV Cache 的物理布局结果传递给后续组件；</li><li>scheduler 的 token budget、chunk 数量和请求调度语义不变；</li><li>不满足 Qwen3.5、ROCm、head size、KV head 数量和 prefix 状态条件时，保持原有逻辑。</li></ol><p>这类改动的核心是“让 backend 的物理约束进入既有布局计算”，而不是用一个新参数绕过 vLLM 的缓存模型。</p><h2 id="4-Qwen3-5-的真实页形状"><a href="#4-Qwen3-5-的真实页形状" class="headerlink" title="4. Qwen3.5 的真实页形状"></a>4. Qwen3.5 的真实页形状</h2><p>本文实验中的 full-attention 形状具有很强的确定性：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">query heads: 24</span><br><span class="line">KV heads:     4</span><br><span class="line">GQA ratio:    6</span><br><span class="line">head size:    256</span><br><span class="line">dtype:        BF16</span><br></pre></td></tr></table></figure><p>vLLM 的完整 K&#x2F;V cache tensor 还包含一个 K&#x2F;V 维度，形状可以写成：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">[num_physical_pages, 2, BLOCK_SIZE, 4, 256]</span><br></pre></td></tr></table></figure><p>在 <code>unbind(1)</code> 之后，单独的 K 或 V cache 形状才是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">[num_physical_pages, BLOCK_SIZE, 4, 256]</span><br></pre></td></tr></table></figure><p>K 和 V 分开存储时，单个 page 的数据量近似为：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">BLOCK_SIZE × 4 × 256 × sizeof(BF16)</span><br></pre></td></tr></table></figure><p>K、V 两者合计则再乘以 2。换成 page832 后，单页会多放一些 token，但页内每个 64-token group 都是完整的。这个增加的容量成本必须和 kernel 访存效率一起评估，不能只看单页耗时。</p><h2 id="5-从-page-table-到-coalesced-load"><a href="#5-从-page-table-到-coalesced-load" class="headerlink" title="5. 从 page table 到 coalesced load"></a>5. 从 page table 到 coalesced load</h2><p>通用 Triton paged-attention kernel 的核心循环可以抽象成下面的形式：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">for</span> tile <span class="keyword">in</span> key_tiles:</span><br><span class="line">    token = tile * TILE_SIZE + lane_offset</span><br><span class="line">    logical_page = token // BLOCK_SIZE</span><br><span class="line">    page_offset = token % BLOCK_SIZE</span><br><span class="line">    physical_page = block_table[logical_page]</span><br><span class="line"></span><br><span class="line">    K = load(key_cache[physical_page, page_offset, kv_head, dim])</span><br><span class="line">    V = load(value_cache[physical_page, page_offset, kv_head, dim])</span><br><span class="line">    score = dot(Q, K) * scale</span><br><span class="line">    update_online_softmax(score, V)</span><br></pre></td></tr></table></figure><p>在 page784 上，<code>token % BLOCK_SIZE</code> 的结果在页尾会留下一个无法与 64-token 访问组对齐的区间。通用 kernel 可以通过 mask 保证正确性，但 mask 并不会让不规则地址变成规则地址；这里描述的是访存布局的机制推断，不是额外的硬件 trace 计数。</p><p>在 page832 上，页内 token 区间可以自然地分成完整的 64-token 组。vendor kernel 仍然需要处理最后一个请求的真实长度，但不需要在每个物理页内部面对一个固定的 16-token 尾块。</p><p>这里的优化点不是“去掉 causal mask”。因果约束仍然完整保留；改变的是 page 内循环与物理地址布局，使向量加载更适合 DCU 的执行粒度。</p><h2 id="6-为什么-vendor-kernel-只接管严格的单序列-prefill-形状"><a href="#6-为什么-vendor-kernel-只接管严格的单序列-prefill-形状" class="headerlink" title="6. 为什么 vendor kernel 只接管严格的单序列 prefill 形状"></a>6. 为什么 vendor kernel 只接管严格的单序列 prefill 形状</h2><p>长上下文服务不是一种单一 workload。q&#x3D;1 decode、q&#x3D;4096 chunked-prefill 和最后不足 4096 的尾块，在并行度、访存方向和图捕获方式上都不同。</p><p>因此最终 Tail-Q 版本的 vendor dispatch 使用严格条件，而不是全局替换：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line">1 &lt; num_actual_tokens &lt;= 4096</span><br><span class="line">max_seqlen_q == num_actual_tokens</span><br><span class="line">single sequence</span><br><span class="line">decoder attention</span><br><span class="line">BF16 Q/K/V/output</span><br><span class="line">Qwen3.5: 24 Q heads, 4 KV heads, head size 256</span><br><span class="line">page size = 832</span><br><span class="line">GQA ratio = 6</span><br><span class="line">causal = true</span><br><span class="line">no sliding window</span><br><span class="line">no ALiBi / sinks / softcap</span><br><span class="line">no multimodal prefix range</span><br></pre></td></tr></table></figure><p>满足条件时，调用已有的 vendor <code>varlen_fwd_unified</code> 接口：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">q, k, v</span><br><span class="line">cu_seqlens_q</span><br><span class="line">seqused_k</span><br><span class="line">block_table</span><br><span class="line">max_seqlen_q / max_seqlen_k</span><br><span class="line">causal = true</span><br></pre></td></tr></table></figure><p>不满足条件就回到原有 unified Triton attention。这个设计有两个工程意义：</p><ul><li>vendor kernel 只面对它真正擅长的固定形状；</li><li>新路径即使在某个边界上不适合，也不会污染通用 attention。</li></ul><p>最初的候选只覆盖 q&#x3D;4096；最终 Tail-Q 版本覆盖 <code>1 &lt; q &lt;= 4096</code> 且 <code>max_seqlen_q == q</code>，明确排除 q&#x3D;1 decode。这样最后一个不满 4096 的 prefill chunk 也能进入同一类完整 causal attention，而不会改变 scheduler 的切块逻辑。</p><h2 id="7-decode-为什么要单独维护-page-contiguous-路径"><a href="#7-decode-为什么要单独维护-page-contiguous-路径" class="headerlink" title="7. decode 为什么要单独维护 page-contiguous 路径"></a>7. decode 为什么要单独维护 page-contiguous 路径</h2><p>prefill 的 q 很大，适合让 vendor FlashAttention 处理一整个 query chunk；decode 的 q 通常为 1，主要问题变成从大量历史 page 中读取 K&#x2F;V，并以低 launch 开销完成在线 softmax。</p><p>page-contiguous decode kernel 的循环更接近：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">for</span> page_idx <span class="keyword">in</span> pages_for_sequence:</span><br><span class="line">    physical_page = block_table[page_idx]</span><br><span class="line">    <span class="keyword">for</span> local_tile <span class="keyword">in</span> tiles_inside_page:</span><br><span class="line">        page_offset = local_tile * TILE_SIZE + offsets</span><br><span class="line">        K = load(K_cache[physical_page, page_offset, ...])</span><br><span class="line">        V = load(V_cache[physical_page, page_offset, ...])</span><br><span class="line">        update_online_softmax(Q, K, V)</span><br></pre></td></tr></table></figure><p>这个路径仍然解析 block table，没有跳过任何逻辑 page，也没有改变因果范围。它只是针对 q&#x3D;1、GQA6、head size 256 的访问几何减少通用调度开销，并同时支持 page784 和 page832。</p><p>在 page832 上，独立 CUDAGraph microbench 的 decode 加速约为：</p><table><thead><tr><th align="right">KV 长度</th><th align="right">通用 page832</th><th align="right">page-contiguous</th><th align="right">加速比</th></tr></thead><tbody><tr><td align="right">8192</td><td align="right">0.115989 ms</td><td align="right">0.080010 ms</td><td align="right">1.45x</td></tr><tr><td align="right">16384</td><td align="right">0.154312 ms</td><td align="right">0.122411 ms</td><td align="right">1.26x</td></tr><tr><td align="right">32768</td><td align="right">0.405256 ms</td><td align="right">0.278832 ms</td><td align="right">1.45x</td></tr></tbody></table><p>这组结果是 bitwise exact 的；但独立 kernel 的加速比不能直接当成端到端服务加速比，因为 decode 还会被 Linear、采样、图 replay 和 Python&#x2F;调度边界包围。</p><h2 id="8-实测结果：kernel-加速如何传到服务层"><a href="#8-实测结果：kernel-加速如何传到服务层" class="headerlink" title="8. 实测结果：kernel 加速如何传到服务层"></a>8. 实测结果：kernel 加速如何传到服务层</h2><p>在同一容器、同一模型、同一原生扩展和同一数据集下，baseline 使用官方 prefix-off page784 路径，candidate 使用 page832、vendor prefill 和 page-contiguous decode。三档每侧均完成 10&#x2F;10、failed&#x3D;0。运行时日志还确认了四个关键事实：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">baseline: prefix caching = False, block size = 784</span><br><span class="line">candidate: prefix caching = False, block size = 832</span><br><span class="line">vendor q=4096 prefill marker: hit</span><br><span class="line">page832 decode marker: hit</span><br></pre></td></tr></table></figure><p>本地三档 A&#x2F;B 的相对变化为：</p><table><thead><tr><th>上下文档位</th><th align="right">吞吐变化</th></tr></thead><tbody><tr><td>4K-8K</td><td align="right">+0.62%</td></tr><tr><td>8K-16K</td><td align="right">+2.67%</td></tr><tr><td>16K-32K</td><td align="right">+13.78%</td></tr></tbody></table><p>q&#x3D;4096 vendor prefill standalone 相对 page784 Triton 路径约为 2.75x-2.92x；完整服务的提升明显小于这个数字。长档收益更明显，是因为该测试 workload 中 q&#x3D;4096 chunk 和长序列 paged-KV 访问占比更高；这是由 workload&#x2F;profile 推出的解释，不应当理解为单独 trace 已经证明了某一种访存事务的精确占比。</p><p>这也说明为什么不能只拿一个 kernel 表格作为结论：在 page832、prefix-off、all-Tail-Q 的官方 8K profile 中，q&#x3D;4096 prefill 的 Attention 总桶从 681.071 ms 降到 238.718 ms；这不是单个 vendor kernel 的 standalone 耗时，端到端服务还要支付 GEMM、GDN、decode 和调度成本。</p><p>这里的数字口径需要分开：2.75x-2.92x 是 standalone kernel 对比，+0.62%&#x2F;+2.67%&#x2F;+13.78% 是同容器端到端 A&#x2F;B，681.071&#x2F;238.718 ms 是 profile 的 Attention 总桶。它们不能互相替代，也不能把本地 A&#x2F;B 倍率直接当作另一台机器的官方绝对吞吐。</p><h2 id="9-数值正确性：快不等于逐位相同"><a href="#9-数值正确性：快不等于逐位相同" class="headerlink" title="9. 数值正确性：快不等于逐位相同"></a>9. 数值正确性：快不等于逐位相同</h2><p>vendor BF16 attention 与 Triton attention 计算的是同一个完整的因果 Attention，但并行归约顺序可能不同。因此：</p><ul><li>vendor standalone 输出有限且重复运行稳定；</li><li>page832 Triton 与 page784 Triton 可以逐位一致；</li><li>vendor 与 Triton 的相对 L2 误差约为 <code>0.0058-0.0064</code>；</li><li>生成文本不保证逐请求 bitwise 相同。</li></ul><p>这不是可以忽略的细节。BF16 reduction 的微小差异可能在长序列生成中被放大，所以必须同时做：</p><ol><li>kernel 输出 finite 检查；</li><li>重复运行一致性检查；</li><li>相对误差与参考 kernel 对比；</li><li>四类任务级 accuracy smoke；</li><li>逐请求记录生成文本和 token 数。</li></ol><p>在这条路线中，没有跳过 Q&#x2F;K&#x2F;V，没有缩短 causal 范围，没有做稀疏注意力，也没有复用跨请求 KV；变化是物理页面布局和等价 kernel 选择。</p><h2 id="10-一个重要的失败案例：page832-曾经“看起来无效”"><a href="#10-一个重要的失败案例：page832-曾经“看起来无效”" class="headerlink" title="10. 一个重要的失败案例：page832 曾经“看起来无效”"></a>10. 一个重要的失败案例：page832 曾经“看起来无效”</h2><p>这条路线最有价值的发现并不是第一次跑出 2.9x，而是后来发现旧结论的测试路径并不完整。</p><p>旧实验只提交了 attention dispatch 改动，却没有同时提交 page-layout 对齐逻辑。官方 loader 关闭 prefix caching，实际运行时仍是 page784；由于 784 不满足 vendor kernel 的 64-token 页要求，vendor 路径根本没有命中。</p><p>另一方面，旧 fast-start 脚本打开了 prefix caching，Mamba align 又把页面抬到了 1024。本地看到的结果因此是 page1024，不是官方路径的 page832。</p><p>于是出现了一个典型矛盾：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">standalone page832 vendor：很快</span><br><span class="line">旧官方提交：几乎没收益</span><br></pre></td></tr></table></figure><p>正确结论不是“standalone 不可信”，也不是“官方一定不适合”，而是要先检查：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">源码是否真的包含布局改动？</span><br><span class="line">运行时 block size 是多少？</span><br><span class="line">vendor marker 是否出现？</span><br><span class="line">prefix caching 是否改变了页面？</span><br></pre></td></tr></table></figure><p>只有这些条件对齐，性能数字才属于同一条路径。</p><h2 id="11-复现与验收清单"><a href="#11-复现与验收清单" class="headerlink" title="11. 复现与验收清单"></a>11. 复现与验收清单</h2><p>如果要在另一台 DCU 或另一个 vLLM 版本复现，建议按以下顺序：</p><h3 id="11-1-先确认硬件和软件约束"><a href="#11-1-先确认硬件和软件约束" class="headerlink" title="11.1 先确认硬件和软件约束"></a>11.1 先确认硬件和软件约束</h3><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">确认 gfx936/对应 DCU 目标架构</span><br><span class="line">确认 wave64 相关 kernel 约束</span><br><span class="line">确认 vendor FlashAttention 的 page 对齐要求</span><br><span class="line">确认 BF16、GQA6、head size 256 的真实模型形状</span><br></pre></td></tr></table></figure><h3 id="11-2-再确认页面计算"><a href="#11-2-再确认页面计算" class="headerlink" title="11.2 再确认页面计算"></a>11.2 再确认页面计算</h3><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">打印 prefix caching 状态</span><br><span class="line">打印最终 cache block size</span><br><span class="line">打印 attention page bytes 与 Mamba page bytes</span><br><span class="line">确认 page832 来自对齐计算，而不是脚本里的隐藏参数</span><br></pre></td></tr></table></figure><h3 id="11-3-最后确认-kernel-命中"><a href="#11-3-最后确认-kernel-命中" class="headerlink" title="11.3 最后确认 kernel 命中"></a>11.3 最后确认 kernel 命中</h3><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">打印 vendor prefill marker</span><br><span class="line">打印 page-contiguous decode marker</span><br><span class="line">检查不满足条件时是否回退到 unified Triton</span><br><span class="line">检查 block table 和 K/V stride 与 page size 一致</span><br></pre></td></tr></table></figure><h3 id="11-4-端到端验收"><a href="#11-4-端到端验收" class="headerlink" title="11.4 端到端验收"></a>11.4 端到端验收</h3><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">同一容器、同一模型、同一数据集</span><br><span class="line">充分 warmup，避免首次编译污染</span><br><span class="line">三档吞吐，failed = 0</span><br><span class="line">记录 TTFT、TPOT、输出 token 数和文本</span><br><span class="line">accuracy smoke</span><br></pre></td></tr></table></figure><h2 id="12-结论：真正的优化发生在“硬件约束”和“框架语义”的交界处"><a href="#12-结论：真正的优化发生在“硬件约束”和“框架语义”的交界处" class="headerlink" title="12. 结论：真正的优化发生在“硬件约束”和“框架语义”的交界处"></a>12. 结论：真正的优化发生在“硬件约束”和“框架语义”的交界处</h2><p>page832 不是一个孤立的调参结果，它同时涉及：</p><ul><li>gfx936 的 wave64 执行粒度；</li><li>vendor attention 对 64-token 页的要求；</li><li>vLLM 混合 Attention&#x2F;Mamba 的页面兼容计算；</li><li>paged KV 的 block table 和 stride 地址公式；</li><li>q&#x3D;4096 prefill 与 q&#x3D;1 decode 的不同访问几何；</li><li>BF16 归约顺序带来的数值验证问题。</li></ul><p>只改 kernel 而不改页面布局，kernel 可能根本不会命中；只改页面大小而不改 decode 访问，长上下文也可能收益有限；只看 microbench 而不看输出和 official loader，结论还可能被错误路径污染。</p><p>对 DCU 推理优化而言，最值得复用的方法是：</p><blockquote><p><strong>先把硬件的访问粒度翻译成数据布局约束，再把布局约束接入框架已有的语义路径，最后用运行时 marker 和端到端结果证明 kernel 确实命中。</strong></p></blockquote><p>这比“换一个 BLOCK_SIZE 看看”更接近真正的 kernel 工程，也更能解释为什么一个 48-token 的页面差异，会在每层 attention 和每个长请求的多个物理页访问中反复出现并影响整体服务性能。</p><hr><p>本文讨论的是公开可解释的硬件、布局和 kernel 方法；实现描述不包含内部仓库地址、账号信息、容器凭据或私有基础设施细节。性能数字来自固定形状的同容器实验，实际结果会随 DCU 型号、驱动、DTK、编译缓存和运行时负载变化。</p>]]>
    </content>
    <id>https://luanjiahao.qzz.io/2026/07/17/dcu-page832-attention/</id>
    <link href="https://luanjiahao.qzz.io/2026/07/17/dcu-page832-attention/"/>
    <published>2026-07-17T04:00:00.000Z</published>
    <summary>
      <![CDATA[<h2 id="摘要"><a href="#摘要" class="headerlink" title="摘要"></a>摘要</h2><p>在大模型推理中，Attention 的公式通常不是最难的部分，真正决定性能的往往是数据如何落在显存里，以及硬件一次能够以什么粒度把这些数据取出来。</p>
<p>本文以 Qwen3.5 的官方 prefix-off 长上下文路径为例，讨论一个看似很小、实际上贯穿 vLLM 配置、KV Cache、paged attention 和 DCU kernel dispatch 的问题：该路径的默认物理 KV Cache page 是 784 tokens，而海光]]>
    </summary>
    <title>海光 DCU 长上下文 Attention：从 page784 到 page832</title>
    <updated>2026-08-28T11:20:48.104Z</updated>
  </entry>
  <entry>
    <author>
      <name>YFGUG</name>
    </author>
    <category term="项目" scheme="https://luanjiahao.qzz.io/categories/%E9%A1%B9%E7%9B%AE/"/>
    <category term="海光 DCU" scheme="https://luanjiahao.qzz.io/tags/%E6%B5%B7%E5%85%89-DCU/"/>
    <category term="Qwen3.5" scheme="https://luanjiahao.qzz.io/tags/Qwen3-5/"/>
    <category term="vLLM" scheme="https://luanjiahao.qzz.io/tags/vLLM/"/>
    <category term="Prefill" scheme="https://luanjiahao.qzz.io/tags/Prefill/"/>
    <category term="Decode" scheme="https://luanjiahao.qzz.io/tags/Decode/"/>
    <category term="性能分析" scheme="https://luanjiahao.qzz.io/tags/%E6%80%A7%E8%83%BD%E5%88%86%E6%9E%90/"/>
    <content>
      <![CDATA[<h2 id="摘要"><a href="#摘要" class="headerlink" title="摘要"></a>摘要</h2><p>在大模型推理优化中，最容易被误读的一件事，是把某个 kernel 的局部加速比直接当成服务吞吐的提升。一个 kernel 即使在独立测试中快了 1.08 倍，完整服务也可能只快几个百分点，甚至几乎没有变化。</p><p>本文以海光 DCU gfx936 路径上的 Qwen3.5-27B BF16 推理为例，使用本地已经完成的 profile、kernel microbench 和同容器端点 A&#x2F;B 数据，回答三个问题：</p><ol><li>Prefill 和 Decode 分别被什么算子限制；</li><li>为什么 BF16 的 M&#x3D;1 GEMM&#x2F;GEMV 会成为 Decode 的主要性能墙；</li><li>一个局部优化要经过哪些验证，才能判断它确实改善了完整推理服务。</li></ol><p>本文只讨论 DCU 推理性能工程。文中所有性能数字均来自本地固定环境的已有测量记录；独立 kernel 数据、profile 数据和端到端服务数据分别标注，不把它们混成一个结论。</p><hr><h2 id="1-先把推理拆成-Prefill-和-Decode"><a href="#1-先把推理拆成-Prefill-和-Decode" class="headerlink" title="1. 先把推理拆成 Prefill 和 Decode"></a>1. 先把推理拆成 Prefill 和 Decode</h2><p>一次生成请求通常可以分成两个阶段：</p><ul><li><strong>Prefill</strong>：一次处理较长的输入，把提示词转换成初始状态；</li><li><strong>Decode</strong>：之后逐 token 生成，每一步通常只处理一个新 token，同时读取此前保存的状态。</li></ul><p>这两个阶段看起来属于同一个模型，硬件行为却很不一样。</p><p>Prefill 的 query 维度较大，矩阵乘法更接近大 GEMM，Attention 也能够在较大的 token 块上并行。Decode 的 query 维度通常是 1，很多矩阵运算退化成 M&#x3D;1 的 GEMM 或 GEMV：计算量不大，但每一步都要读取很大的权重，并且要承担大量 kernel launch 和运行时调度开销。</p><p>可以用一个简化的时间模型表示完整请求：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">request time = prefill GEMM</span><br><span class="line">             + prefill attention</span><br><span class="line">             + other prefill linear layers</span><br><span class="line">             + decode linear layers</span><br><span class="line">             + decode state access</span><br><span class="line">             + sampling and runtime overhead</span><br></pre></td></tr></table></figure><p>因此，“Attention 很快”或者“某个 GEMM 很快”都只说明了总时间中的一个部分。真正值得关注的是：这个部分在目标 workload 中占多大比例，以及它是否会在每个生成 token 中反复出现。</p><hr><h2 id="2-实验环境与测量协议"><a href="#2-实验环境与测量协议" class="headerlink" title="2. 实验环境与测量协议"></a>2. 实验环境与测量协议</h2><p>本文引用的本地测量使用以下环境和口径：</p><table><thead><tr><th>项目</th><th>配置</th></tr></thead><tbody><tr><td>加速卡</td><td>海光 DCU，gfx936-class 路径</td></tr><tr><td>模型</td><td>Qwen3.5-27B</td></tr><tr><td>权重计算类型</td><td>BF16 原始权重</td></tr><tr><td>PyTorch</td><td>2.10.0</td></tr><tr><td>推理框架</td><td>vLLM 本地运行环境</td></tr><tr><td>Standalone warmup</td><td>10 次</td></tr><tr><td>Standalone measured iterations</td><td>40 次</td></tr><tr><td>端点测试</td><td>固定请求集、并发 1、同一容器 A&#x2F;B</td></tr><tr><td>主要指标</td><td>output throughput、mean&#x2F;p99 TPOT、TTFT、输出文本、失败数</td></tr></tbody></table><p>测量分成三层：</p><ol><li><strong>Profile</strong>：回答时间消耗在哪里；</li><li><strong>Standalone&#x2F;CUDAGraph microbench</strong>：回答目标 kernel 的局部上限；</li><li><strong>端点 A&#x2F;B</strong>：回答完整服务实际得到多少收益。</li></ol><p>这三层不能互相替代。Profile 适合定位问题，不适合直接当吞吐成绩；microbench 适合筛选实现，不适合直接代表服务；端点 A&#x2F;B 才能验证全链路效果。</p><p>本文中的 profile、RPB2 端点和 Tail-M 反例来自不同的历史实验对象：它们用于解释不同层次的性能现象，不是同一个源码组合的叠加结果，文中的提升数字不能相加。</p><hr><h2 id="3-一次长上下文-profile-看到了什么"><a href="#3-一次长上下文-profile-看到了什么" class="headerlink" title="3. 一次长上下文 profile 看到了什么"></a>3. 一次长上下文 profile 看到了什么</h2><p>本地 profile 记录了一个 page832、prefix caching 关闭、all-Tail-Q vendor attention 的官方 8K prompt 路径请求的阶段耗时：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">q=4096 prefill total: 2720.145 ms</span><br><span class="line">q=1685 prefill total:  464.801 ms</span><br><span class="line">decode total:        2586.460 ms</span><br><span class="line">decode average:        43.108 ms/token</span><br></pre></td></tr></table></figure><h3 id="3-1-Prefill：Attention-之后，大-GEMM-成为主要时间桶"><a href="#3-1-Prefill：Attention-之后，大-GEMM-成为主要时间桶" class="headerlink" title="3.1 Prefill：Attention 之后，大 GEMM 成为主要时间桶"></a>3.1 Prefill：Attention 之后，大 GEMM 成为主要时间桶</h3><p>在 q&#x3D;4096 的 Prefill 中，主要时间项如下：</p><table><thead><tr><th>时间项</th><th align="right">耗时</th></tr></thead><tbody><tr><td>Attention</td><td align="right">238.718 ms</td></tr><tr><td>aten::mm</td><td align="right">1840.596 ms</td></tr><tr><td>aten::mm 占 q&#x3D;4096 Prefill</td><td align="right">67.67%</td></tr></tbody></table><p>这组数据给出了一个很直接的判断：当 Attention 已经被压低之后，继续在 Attention 的边界参数上做小范围调整，收益会受到总时间占比限制。此时更值得研究的是大 GEMM 的数据访问、tile 组织和实际调用形状。</p><p>这里的 aten::mm 不是一个抽象的“模型耗时”，而是一批真实矩阵乘法调用的汇总。要继续优化，必须把它拆成具体的 M、N、K 形状、dtype、调用频率和是否被其他算子覆盖，而不是只看总桶名称。</p><h3 id="3-2-Decode：Linear-GEMV-占据绝大多数时间"><a href="#3-2-Decode：Linear-GEMV-占据绝大多数时间" class="headerlink" title="3.2 Decode：Linear&#x2F;GEMV 占据绝大多数时间"></a>3.2 Decode：Linear&#x2F;GEMV 占据绝大多数时间</h3><p>同一份 profile 的 Decode 统计如下：</p><table><thead><tr><th>时间项</th><th align="right">每 token 耗时</th></tr></thead><tbody><tr><td>Linear 总计</td><td align="right">38.078 ms</td></tr><tr><td>LLMM1</td><td align="right">23.470 ms</td></tr><tr><td>rocBLAS GEMV</td><td align="right">14.607 ms</td></tr><tr><td>Linear 占 Decode</td><td align="right">88.33%</td></tr></tbody></table><p>每个 Decode token 中观察到：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">LLMM1:        176 launches/token</span><br><span class="line">rocBLAS GEMV: 129 launches/token</span><br></pre></td></tr></table></figure><p>129 次 GEMV 的来源可以按模块归纳为：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">64  MLP down projection</span><br><span class="line">48  GDN output projection</span><br><span class="line">16  attention output projection</span><br><span class="line">1   lm_head</span><br></pre></td></tr></table></figure><p>这说明 Decode 的优化重点和 Prefill 不同。Decode 不是单纯寻找一个“更快的 Attention”，而是要处理大量小矩阵运算：每一步都重复执行，单次节省很小，但乘上层数和生成 token 数后可能形成可观的端到端差异。</p><hr><h2 id="4-为什么-DCU-上的-BF16-M-1-运算特别难"><a href="#4-为什么-DCU-上的-BF16-M-1-运算特别难" class="headerlink" title="4. 为什么 DCU 上的 BF16 M&#x3D;1 运算特别难"></a>4. 为什么 DCU 上的 BF16 M&#x3D;1 运算特别难</h2><p>当矩阵乘法的 M 维度为 1 时，计算并行度相对有限，而权重矩阵仍然很大。此时 kernel 往往更接近受权重读取和访存效率限制，而不是受理论 FLOPS 限制。</p><p>一个简化的 M&#x3D;1 计算可以写成：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">y[j] = sum_k x[k] * W[j, k]</span><br></pre></td></tr></table></figure><p>对每个输出行都要遍历 K 维权重。性能会受到以下因素共同影响：</p><ul><li>权重加载是否连续、是否能够合并访问；</li><li>一个 block 处理多少输出行；</li><li>K 维归约时 wave 内线程如何协作；</li><li>BF16 读取和 FP32 累加之间的转换开销；</li><li>输出写回是否造成额外同步；</li><li>kernel launch 和图 replay 是否已经成为主要成本。</li></ul><p>因此，M&#x3D;1 kernel 的优化通常不是简单地把线程数调大。线程过多会增加归约和同步成本，线程过少又无法充分隐藏权重读取延迟。真正有效的配置往往只适合一组固定的模型形状。</p><p>本文中的 RPB2 实验保持 BF16 原始权重，并保留已验证的 FP32 pair accumulation，只调整同一 LLMM1 实现的输出行分块方式。这里的 RPB 可以理解为 rows per block：一个 block 负责的输出行数量不同，权重读取和归约的组织也会随之变化。</p><hr><h2 id="5-BF16-M-1-GEMM-的本地-standalone-结果"><a href="#5-BF16-M-1-GEMM-的本地-standalone-结果" class="headerlink" title="5. BF16 M&#x3D;1 GEMM 的本地 standalone 结果"></a>5. BF16 M&#x3D;1 GEMM 的本地 standalone 结果</h2><p>在相同 DCU、相同 BF16 输入和相同编译扩展下，对比 RPB4 与 RPB2：</p><table><thead><tr><th>权重形状</th><th align="right">RPB4</th><th align="right">RPB2</th><th align="right">加速比</th><th align="right">最大差异</th></tr></thead><tbody><tr><td>14336 x 5120</td><td align="right">0.114160 ms</td><td align="right">0.106480 ms</td><td align="right">1.0721x</td><td align="right">0</td></tr><tr><td>16384 x 5120</td><td align="right">0.131040 ms</td><td align="right">0.120960 ms</td><td align="right">1.0833x</td><td align="right">0</td></tr><tr><td>34816 x 5120</td><td align="right">0.272480 ms</td><td align="right">0.250400 ms</td><td align="right">1.0882x</td><td align="right">0</td></tr></tbody></table><p>测试条件为 warmup 10 次、正式测量 40 次。三种形状的输出都与参考路径逐位一致，重复运行结果稳定。根据权重矩阵字节数除以实测 kernel 时间估算，有效权重带宽约为 1.38-1.42 TB&#x2F;s；这是该 kernel 的有效带宽估算，不是 DCU 峰值 HBM 带宽的直接测量。</p><p>这组数据说明 RPB2 的收益是真实的局部执行收益，但它还没有回答服务层问题。Standalone 只测了目标矩阵乘法，完整 Decode 还包括其他 Linear、状态读取、采样和运行时开销。</p><hr><h2 id="6-从-kernel-到服务：同容器-A-B-才是关键一关"><a href="#6-从-kernel-到服务：同容器-A-B-才是关键一关" class="headerlink" title="6. 从 kernel 到服务：同容器 A&#x2F;B 才是关键一关"></a>6. 从 kernel 到服务：同容器 A&#x2F;B 才是关键一关</h2><p>为了判断局部收益能否传递到完整推理，本地在同一容器、同一模型、同一请求集和相同运行条件下进行了 A&#x2F;B。基线配置和 RPB2 都使用 BF16 原始权重，端点指标如下：</p><table><thead><tr><th>档位</th><th align="right">Baseline output tok&#x2F;s</th><th align="right">RPB2 output tok&#x2F;s</th><th align="right">吞吐变化</th><th align="right">Baseline p99 TPOT</th><th align="right">RPB2 p99 TPOT</th></tr></thead><tbody><tr><td>4K-8K</td><td align="right">18.8369</td><td align="right">19.5961</td><td align="right">+4.03%</td><td align="right">46.9627 ms</td><td align="right">44.9923 ms</td></tr><tr><td>8K-16K</td><td align="right">14.5837</td><td align="right">15.0306</td><td align="right">+3.06%</td><td align="right">47.5920 ms</td><td align="right">45.5662 ms</td></tr><tr><td>16K-32K</td><td align="right">11.7495</td><td align="right">12.0286</td><td align="right">+2.38%</td><td align="right">49.8507 ms</td><td align="right">47.8545 ms</td></tr></tbody></table><p>端点结果与 standalone 的关系很典型：局部 kernel 是 1.07x-1.09x，服务层不是同样的 7%-9% 变化，而是 2.38%-4.03%。原因是 RPB2 只影响一部分重复执行的 Linear 工作，其余时间仍由其他算子和运行时组成。</p><p>正确性和稳定性检查如下：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">4K-8K:   2644 vs 2644 output tokens，10/10 文本 byte-identical</span><br><span class="line">8K-16K:  1768 vs 1768 output tokens，10/10 文本 byte-identical</span><br><span class="line">16K-32K: 1877 vs 1877 output tokens，10/10 文本 byte-identical</span><br><span class="line">failed:  0</span><br></pre></td></tr></table></figure><p>TTFT 基本不变：</p><table><thead><tr><th>档位</th><th align="right">Baseline p99 TTFT</th><th align="right">RPB2 p99 TTFT</th></tr></thead><tbody><tr><td>4K-8K</td><td align="right">2049.317 ms</td><td align="right">2047.251 ms</td></tr><tr><td>8K-16K</td><td align="right">4620.050 ms</td><td align="right">4614.045 ms</td></tr><tr><td>16K-32K</td><td align="right">7044.241 ms</td><td align="right">7037.622 ms</td></tr></tbody></table><p>这符合优化位置的预期：RPB2 主要作用在 Decode 的 M&#x3D;1 Linear，首 token 的 Prefill 路径没有发生明显变化，因此 TTFT 基本保持不变，而 TPOT 得到改善。</p><hr><h2 id="7-为什么-1-08x-kernel-不会自动变成-1-08x-服务"><a href="#7-为什么-1-08x-kernel-不会自动变成-1-08x-服务" class="headerlink" title="7. 为什么 1.08x kernel 不会自动变成 1.08x 服务"></a>7. 为什么 1.08x kernel 不会自动变成 1.08x 服务</h2><p>可以用一个粗略的 Amdahl 模型理解这个现象。假设目标 kernel 占总时间的比例为 f，局部加速为 s，理想情况下整体加速上限近似为：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">service speedup ≈ 1 / ((1 - f) + f / s)</span><br></pre></td></tr></table></figure><p>当 s &#x3D; 1.08 时，如果目标 kernel 只覆盖总时间的一部分，整体收益必然小于 8%。实际服务还会受到更多因素影响：</p><ul><li>不同层的矩阵形状不完全相同；</li><li>某些调用仍然走原有实现；</li><li>kernel launch、graph replay 和状态访问无法被目标 kernel 消除；</li><li>Prefill 的大 GEMM 与 Decode 的小 GEMM 位于不同时间阶段；</li><li>测量中还存在请求长度、缓存和系统调度带来的波动。</li></ul><p>所以端到端 A&#x2F;B 的正确问题不是“这个 kernel 快了几倍”，而是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">它覆盖了多少真实调用？</span><br><span class="line">这些调用占总时间多少？</span><br><span class="line">它是否改变了其他路径的行为？</span><br></pre></td></tr></table></figure><p>RPB2 的本地结果之所以有意义，正是因为它同时满足了三点：目标调用在 Decode 中出现很多次、局部收益在多个真实形状上重复出现、输出和请求行为保持一致。</p><hr><h2 id="8-一个反例：Standalone-有收益，端点几乎不变"><a href="#8-一个反例：Standalone-有收益，端点几乎不变" class="headerlink" title="8. 一个反例：Standalone 有收益，端点几乎不变"></a>8. 一个反例：Standalone 有收益，端点几乎不变</h2><p>同一批本地实验还提供了一个很有代表性的反例。对五类大 GEMM 的 145 个 shape、M 组合进行筛选后，有 106 个形状同时满足：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">BF16 输出 hash 与默认路径一致</span><br><span class="line">Standalone CUDAGraph speedup &gt;= 1.01x</span><br></pre></td></tr></table></figure><p>单独看这些形状，它们都像是值得接入的优化。接入完整服务后，固定中档请求集的结果却只有：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">output throughput: 16.583027 -&gt; 16.586315 tok/s</span><br><span class="line">increment:         +0.0198%</span><br><span class="line">texts:             10/10 exact</span><br><span class="line">tokens:            1809 -&gt; 1809</span><br><span class="line">failed:            0 -&gt; 0</span><br></pre></td></tr></table></figure><p>另一个保持精确输出的 gate&#x2F;up GEMM 方案，在稳态同容器 A&#x2F;B 中的平均收益也只有 +0.3250%。这并不说明 standalone 测量错误，而是说明这些调用在完整请求中的时间占比、调用覆盖率或可重叠程度不足以形成明显的服务收益。</p><p>这个反例非常重要：</p><blockquote><p>Standalone speedup 是局部上限，端点 A&#x2F;B 才是产品层收益。</p></blockquote><p>如果只根据 microbench 选择方案，很容易把几十个小的正收益叠加成一个看起来很大的预测，但实际运行时它们可能互相覆盖、被其他时间项淹没，或者只命中很少的真实请求。</p><hr><h2 id="9-正确性和稳定性应该怎样验"><a href="#9-正确性和稳定性应该怎样验" class="headerlink" title="9. 正确性和稳定性应该怎样验"></a>9. 正确性和稳定性应该怎样验</h2><p>性能优化不能只记录一个耗时数字。对 DCU 上的 BF16 kernel，至少要同时检查以下内容。</p><h3 id="9-1-Kernel-层"><a href="#9-1-Kernel-层" class="headerlink" title="9.1 Kernel 层"></a>9.1 Kernel 层</h3><ul><li>输出是否为 finite；</li><li>输出 shape 和 stride 是否正确；</li><li>与参考路径的最大绝对差异或 hash；</li><li>重复运行是否稳定；</li><li>不同 batch、不同 M 和边界形状是否仍然正确。</li></ul><h3 id="9-2-服务层"><a href="#9-2-服务层" class="headerlink" title="9.2 服务层"></a>9.2 服务层</h3><ul><li>每个请求是否完成；</li><li>输出 token 数是否一致；</li><li>生成文本是否逐字节一致，或是否符合预设精度标准；</li><li>mean&#x2F;p99 TPOT 是否改善；</li><li>TTFT 是否出现回归；</li><li>是否有失败请求、超时或异常重试。</li></ul><h3 id="9-3-测量层"><a href="#9-3-测量层" class="headerlink" title="9.3 测量层"></a>9.3 测量层</h3><ul><li>两种配置是否使用同一模型与同一扩展；</li><li>是否在同一容器、同一请求集下比较；</li><li>是否把编译、首次加载和缓存建立时间单独处理；</li><li>是否采用足够的 warmup；</li><li>是否重复运行确认方向，而不是只相信一次结果。</li></ul><p>尤其要避免把“代码路径存在”当成“kernel 已命中”。最可靠的做法是在运行时记录真实 dispatch、输入形状和选中的实现，再将 marker 与性能结果一一对应。</p><hr><h2 id="10-一套可复用的-DCU-推理优化流程"><a href="#10-一套可复用的-DCU-推理优化流程" class="headerlink" title="10. 一套可复用的 DCU 推理优化流程"></a>10. 一套可复用的 DCU 推理优化流程</h2><h3 id="第一步：先区分-Prefill-和-Decode"><a href="#第一步：先区分-Prefill-和-Decode" class="headerlink" title="第一步：先区分 Prefill 和 Decode"></a>第一步：先区分 Prefill 和 Decode</h3><p>分别测 TTFT、TPOT 和阶段耗时。不要用 Decode 的优化方法解释 Prefill，也不要用 Prefill 的大 GEMM 结论代替 Decode 的 M&#x3D;1 分析。</p><h3 id="第二步：用-profile-找时间墙"><a href="#第二步：用-profile-找时间墙" class="headerlink" title="第二步：用 profile 找时间墙"></a>第二步：用 profile 找时间墙</h3><p>先确认是大 GEMM、GEMV、Attention、状态读写还是运行时开销。只有知道时间占比，才知道局部加速是否有足够的物理预算。</p><h3 id="第三步：还原真实调用形状"><a href="#第三步：还原真实调用形状" class="headerlink" title="第三步：还原真实调用形状"></a>第三步：还原真实调用形状</h3><p>记录 M、N、K、dtype、batch、调用次数和输出写回方式。泛化矩阵的 microbench 只能作为参考，不能替代真实形状。</p><h3 id="第四步：在-standalone-中筛选"><a href="#第四步：在-standalone-中筛选" class="headerlink" title="第四步：在 standalone 中筛选"></a>第四步：在 standalone 中筛选</h3><p>先用固定 warmup 和多次迭代测局部耗时，同时检查正确性和确定性。对明显低于门槛的实现，不要急着接入服务。</p><h3 id="第五步：做同容器端点-A-B"><a href="#第五步：做同容器端点-A-B" class="headerlink" title="第五步：做同容器端点 A&#x2F;B"></a>第五步：做同容器端点 A&#x2F;B</h3><p>保持模型、数据集、并发、采样设置和运行环境一致，分别记录吞吐、TPOT、TTFT、输出长度和失败数。候选必须在多个档位或多个真实形状上重复出现正向信号。</p><h3 id="第六步：保留负结果"><a href="#第六步：保留负结果" class="headerlink" title="第六步：保留负结果"></a>第六步：保留负结果</h3><p>一个 standalone 正向但端点无收益的方案，同样是有价值的工程结论。它可以帮助后续工作避免重复测试，并让性能预算更接近真实服务，而不是停留在 kernel 表格上。</p><hr><h2 id="11-结论"><a href="#11-结论" class="headerlink" title="11. 结论"></a>11. 结论</h2><p>本地实测数据呈现出一条比较清晰的 DCU 推理性能链路：</p><ol><li>Prefill 的主要矛盾可以落在大 GEMM，q&#x3D;4096 profile 中 aten::mm 占到 67.67%；</li><li>Decode 的主要矛盾是大量 Linear&#x2F;GEMV，Linear 在该 profile 中占 Decode 的 88.33%；</li><li>BF16 M&#x3D;1 kernel 的小幅局部改善，经过重复调用后可以传递到端点，RPB2 在三档同容器 A&#x2F;B 中带来 2.38%-4.03% 的吞吐提升；</li><li>局部加速并不保证端到端收益，106 个 standalone 正向且 exact 的尾部大 GEMM 形状接入后只有 +0.0198%；</li><li>正确性、dispatch 命中、重复性和测量协议，和 kernel 本身的耗时一样重要。</li></ol><p>对海光 DCU 做推理优化，最值得复用的不是某一个固定的 block 参数，而是一套验证顺序：</p><blockquote><p>先用 profile 找到真实时间墙，再用真实形状做 kernel microbench，最后用同容器端点 A&#x2F;B 验证它是否改善了完整服务。</p></blockquote><p>只有三层证据方向一致，局部的“快”才有资格被称为服务层的“快”。</p><hr><p>本文中的性能数字来自本地固定环境的已有测量记录，仅用于说明该模型、软件栈和 DCU 路径下的工程观察。不同 DCU 型号、驱动、DTK、编译缓存、请求分布和系统负载可能得到不同结果；独立 kernel 加速比不应直接外推为通用服务吞吐。</p>]]>
    </content>
    <id>https://luanjiahao.qzz.io/2026/07/17/dcu-long-context-inference-measurement/</id>
    <link href="https://luanjiahao.qzz.io/2026/07/17/dcu-long-context-inference-measurement/"/>
    <published>2026-07-17T03:00:00.000Z</published>
    <summary>
      <![CDATA[<h2 id="摘要"><a href="#摘要" class="headerlink" title="摘要"></a>摘要</h2><p>在大模型推理优化中，最容易被误读的一件事，是把某个 kernel 的局部加速比直接当成服务吞吐的提升。一个 kernel 即使在独立测试中快了 1.08 倍，完整服务也可能只快几个百分点，甚至几乎没有变化。</p>
<p>本文以海光 DCU gfx936 路径上的 Qwen3.5-27B BF16 推理为例，使用本地已经完成的 profile、kernel microbench 和同容器端点 A&#x2F;B 数据，回答三个问题：</p>
<ol>
<li]]>
    </summary>
    <title>海光 DCU 上 Qwen3.5 推理性能工程：从 Prefill/Decode 热点到端到端收益</title>
    <updated>2026-08-28T11:20:48.104Z</updated>
  </entry>
  <entry>
    <author>
      <name>YFGUG</name>
    </author>
    <category term="项目" scheme="https://luanjiahao.qzz.io/categories/%E9%A1%B9%E7%9B%AE/"/>
    <category term="海光 DCU" scheme="https://luanjiahao.qzz.io/tags/%E6%B5%B7%E5%85%89-DCU/"/>
    <category term="vLLM" scheme="https://luanjiahao.qzz.io/tags/vLLM/"/>
    <category term="性能分析" scheme="https://luanjiahao.qzz.io/tags/%E6%80%A7%E8%83%BD%E5%88%86%E6%9E%90/"/>
    <category term="Profile" scheme="https://luanjiahao.qzz.io/tags/Profile/"/>
    <category term="GPU Kernel" scheme="https://luanjiahao.qzz.io/tags/GPU-Kernel/"/>
    <content>
      <![CDATA[<h2 id="开场"><a href="#开场" class="headerlink" title="开场"></a>开场</h2><p>在海光 DCU 上给大模型做 profile，最难的通常不是“能不能生成 trace”，而是 trace 生成以后该怎么看。</p><p>一次 Qwen3.5-27B BF16 本地采样共产生：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">总事件:       235,964</span><br><span class="line">GPU 事件:      61,804</span><br><span class="line">kernel 事件:   60,124</span><br><span class="line">GPU event 总时长: 5773.493 ms</span><br></pre></td></tr></table></figure><p>直接按 kernel 总耗时排序，只能看到一长串 Tensile、Triton、Attention 和自定义 kernel 名称。它不会主动告诉你：</p><ul><li>哪些属于 Prefill；</li><li>哪些属于 Decode；</li><li>一个 kernel 每 token 调用了多少次；</li><li>调用次数能否对应回模型层数；</li><li>哪个时间桶才是真正值得优化的墙。</li></ul><p>本文不讨论某个新 kernel，而是用这份海光 DCU 实测 trace，演示怎样从事件上下文和调用计数还原完整推理结构。</p><hr><h2 id="1-先限制-Profile-的覆盖范围"><a href="#1-先限制-Profile-的覆盖范围" class="headerlink" title="1. 先限制 Profile 的覆盖范围"></a>1. 先限制 Profile 的覆盖范围</h2><p>这次采样只覆盖一个有明确边界的请求：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">3 个 q=4096 Prefill chunk</span><br><span class="line">1 个 q=1685 Prefill tail</span><br><span class="line">60 个 Decode step</span><br></pre></td></tr></table></figure><p>这是一个很重要的前提。</p><p>如果 profile 覆盖多个并发请求、模型加载、首次编译和长时间生成，事件数量会迅速膨胀，而且很难把某个 kernel 归属到具体阶段。</p><p>对海光 DCU 大模型服务，更可靠的流程是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">服务先完成加载和必要 warmup</span><br><span class="line">-&gt; 开启 profiler</span><br><span class="line">-&gt; 只发送一个有代表性的请求</span><br><span class="line">-&gt; 等请求完整结束</span><br><span class="line">-&gt; 立即停止并保存 trace</span><br></pre></td></tr></table></figure><p>Profile 的目标是回答“时间花在哪里”，不是复现最高吞吐。</p><hr><h2 id="2-第一刀：按执行上下文切开-Prefill-和-Decode"><a href="#2-第一刀：按执行上下文切开-Prefill-和-Decode" class="headerlink" title="2. 第一刀：按执行上下文切开 Prefill 和 Decode"></a>2. 第一刀：按执行上下文切开 Prefill 和 Decode</h2><p>Trace 中每批 GPU 事件都有上层执行上下文。利用 token 数和 generation 状态，可以先分成：</p><table><thead><tr><th>上下文</th><th align="right">GPU event 总时长</th></tr></thead><tbody><tr><td>三个 q&#x3D;4096 Prefill chunk</td><td align="right">2720.145 ms</td></tr><tr><td>一个 q&#x3D;1685 Prefill tail</td><td align="right">464.801 ms</td></tr><tr><td>60 个 Decode step</td><td align="right">2586.460 ms</td></tr></tbody></table><p>Decode 平均每步：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">2586.460 ms / 60</span><br><span class="line">= 43.108 ms/token</span><br></pre></td></tr></table></figure><p>这一步比直接看 top kernel 更重要。</p><p>同一个 GEMM 名称可能同时出现在 Prefill 和 Decode，但两个阶段的 M 维、并行度和优化方法完全不同。只有先按上下文切开，后面的统计才有物理意义。</p><hr><h2 id="3-第二刀：用调用次数识别模型结构"><a href="#3-第二刀：用调用次数识别模型结构" class="headerlink" title="3. 第二刀：用调用次数识别模型结构"></a>3. 第二刀：用调用次数识别模型结构</h2><h3 id="3-1-LLMM1-kernel"><a href="#3-1-LLMM1-kernel" class="headerlink" title="3.1 LLMM1 kernel"></a>3.1 LLMM1 kernel</h3><p>Decode 区间中，LLMM1 总计：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">10,560 launches</span><br><span class="line">1,408.219 ms</span><br></pre></td></tr></table></figure><p>除以 60 个 Decode step：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">10,560 / 60 = 176 launches/token</span><br><span class="line">1,408.219 / 60 = 23.470 ms/token</span><br></pre></td></tr></table></figure><p>176 不是一个随机数字，它可以完整映射回模型结构：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">64 次 MLP gate/up</span><br><span class="line">48 次 GDN qkvz</span><br><span class="line">48 次 GDN BA</span><br><span class="line">16 次 Full-Attention QKV</span><br><span class="line">-------------------------</span><br><span class="line">176 次/token</span><br></pre></td></tr></table></figure><p>当 trace 调用数与模型层数完全闭合时，说明 kernel 归因基本可信。</p><h3 id="3-2-rocBLAS-Tensile-GEMV"><a href="#3-2-rocBLAS-Tensile-GEMV" class="headerlink" title="3.2 rocBLAS&#x2F;Tensile GEMV"></a>3.2 rocBLAS&#x2F;Tensile GEMV</h3><p>同一 Decode 区间中，rocBLAS&#x2F;Tensile GEMV 为：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">7,740 launches</span><br><span class="line">876.431 ms</span><br></pre></td></tr></table></figure><p>换算后：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">7,740 / 60 = 129 launches/token</span><br><span class="line">876.431 / 60 = 14.607 ms/token</span><br></pre></td></tr></table></figure><p>129 同样可以映射回模块：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">64 次 MLP down projection</span><br><span class="line">48 次 GDN output projection</span><br><span class="line">16 次 Attention output projection</span><br><span class="line"> 1 次 lm_head</span><br><span class="line">--------------------------------</span><br><span class="line">129 次/token</span><br></pre></td></tr></table></figure><p>这类“调用计数闭环”很有价值。它不仅告诉我们 kernel 热不热，还能检查：</p><ul><li>是否漏统计了某类 Linear；</li><li>某个融合是否真正减少了 launch；</li><li>runtime marker 与实际调用数是否一致；</li><li>模型结构和 trace 是否来自同一版本。</li></ul><hr><h2 id="4-第三刀：把时间换算成每-token-预算"><a href="#4-第三刀：把时间换算成每-token-预算" class="headerlink" title="4. 第三刀：把时间换算成每 token 预算"></a>4. 第三刀：把时间换算成每 token 预算</h2><p>LLMM1 和 rocBLAS 合计：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">23.470 + 14.607</span><br><span class="line">= 38.078 ms/token</span><br></pre></td></tr></table></figure><p>相对于 43.108 ms&#x2F;token 的 Decode：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">38.078 / 43.108</span><br><span class="line">= 88.33%</span><br></pre></td></tr></table></figure><p>这说明该次海光 DCU trace 中，Decode 的主要墙是 Linear，而不是某个零散 pointwise kernel。</p><p>其他主要 Decode 时间包括：</p><table><thead><tr><th>模块</th><th align="right">每 token 时间</th><th align="right">Decode 占比</th></tr></thead><tbody><tr><td>Page-contiguous Attention</td><td align="right">1.630 ms</td><td align="right">3.78%</td></tr><tr><td>Packed GDN recurrent</td><td align="right">0.623 ms</td><td align="right">1.44%</td></tr></tbody></table><p>看到这个比例后，优化优先级会变得清晰：</p><ul><li>一个每层只省 2-3 us 的小融合，很难改变总 TPOT；</li><li>一个覆盖上百次 Linear 的实现，即使单次只快几个百分点，也可能积累出端点收益；</li><li>继续优化占比不足 2% 的桶，需要先证明足够大的绝对节省。</li></ul><p>Profile 的作用不是自动给出方案，而是约束方案的物理上限。</p><hr><h2 id="5-Prefill-不能沿用-Decode-的结论"><a href="#5-Prefill-不能沿用-Decode-的结论" class="headerlink" title="5. Prefill 不能沿用 Decode 的结论"></a>5. Prefill 不能沿用 Decode 的结论</h2><p>三个 q&#x3D;4096 Prefill chunk 的主要时间项为：</p><table><thead><tr><th>时间项</th><th align="right">总耗时</th><th align="right">占 q&#x3D;4096 Prefill</th></tr></thead><tbody><tr><td>aten::mm</td><td align="right">1840.596 ms</td><td align="right">67.67%</td></tr><tr><td>Attention</td><td align="right">238.718 ms</td><td align="right">8.78%</td></tr></tbody></table><p>同一个模型在 Prefill 和 Decode 中呈现出不同形态：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">Prefill:</span><br><span class="line">  大 M GEMM + 长序列 Attention</span><br><span class="line"></span><br><span class="line">Decode:</span><br><span class="line">  M=1 Linear/GEMV + 状态读取</span><br></pre></td></tr></table></figure><p>因此，在 trace 分析中，不能把“Linear 占 Decode 88.33%”理解成整次请求的统一占比，也不能用 Decode 的 M&#x3D;1 kernel 去解释 q&#x3D;4096 Prefill。</p><p>正确做法是分别建立两个预算：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">TTFT 预算 -&gt; Prefill 上下文</span><br><span class="line">TPOT 预算 -&gt; Decode 上下文</span><br></pre></td></tr></table></figure><hr><h2 id="6-为什么只看-kernel-排行会误判"><a href="#6-为什么只看-kernel-排行会误判" class="headerlink" title="6. 为什么只看 kernel 排行会误判"></a>6. 为什么只看 kernel 排行会误判</h2><p>假设 top kernel 列表中第一名是 LLMM1，第二名是某个 Tensile kernel。</p><p>如果不做上下文拆分，至少会遇到三个问题。</p><h3 id="6-1-相同名称可能对应不同-shape"><a href="#6-1-相同名称可能对应不同-shape" class="headerlink" title="6.1 相同名称可能对应不同 shape"></a>6.1 相同名称可能对应不同 shape</h3><p>同一库 kernel 可能被多个 Linear 模块复用。需要结合 M、N、K、调用次数和父事件，才能映射到模型层。</p><h3 id="6-2-总时间高可能只是调用次数多"><a href="#6-2-总时间高可能只是调用次数多" class="headerlink" title="6.2 总时间高可能只是调用次数多"></a>6.2 总时间高可能只是调用次数多</h3><p>一个 130 us 的 kernel 调用一万次，会比一个 5 ms kernel 更靠前。优化时要同时看：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">单次耗时</span><br><span class="line">调用次数</span><br><span class="line">覆盖层数</span><br><span class="line">端点时间占比</span><br></pre></td></tr></table></figure><h3 id="6-3-Profile-自身不是端点-benchmark"><a href="#6-3-Profile-自身不是端点-benchmark" class="headerlink" title="6.3 Profile 自身不是端点 benchmark"></a>6.3 Profile 自身不是端点 benchmark</h3><p>Profiler 会引入记录、同步和缓冲开销。Trace 中的 43.108 ms&#x2F;token 用于建立时间构成，不能直接替代无 profiler 的服务 TPOT。</p><hr><h2 id="7-一次真实的-Profiler-退出故障"><a href="#7-一次真实的-Profiler-退出故障" class="headerlink" title="7. 一次真实的 Profiler 退出故障"></a>7. 一次真实的 Profiler 退出故障</h2><p>这次目标请求已经正常完成，trace 文件也成功写出；但调用 stop_profile 后，ROCm runtime 触发 VMFault，EngineCore 随后退出。</p><p>因此，这次运行的处理方式是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">已写出的 trace:</span><br><span class="line">  作为诊断证据保留</span><br><span class="line"></span><br><span class="line">profiled request 的吞吐和 TPOT:</span><br><span class="line">  不作为端点性能证据</span><br><span class="line"></span><br><span class="line">发生故障后的服务:</span><br><span class="line">  不继续复用做 benchmark</span><br></pre></td></tr></table></figure><p>这个边界非常重要。</p><p>“停止 profiler 时服务崩溃”不代表之前写出的所有 event 都无效；但它说明 profiler 生命周期已经改变了服务状态，不能继续拿同一进程做性能 A&#x2F;B。</p><p>在海光 DCU 上采集长 trace 时，建议把 profiler 服务视为一次性诊断实例。</p><hr><h2 id="8-一套可复用的海光-DCU-Trace-分析流程"><a href="#8-一套可复用的海光-DCU-Trace-分析流程" class="headerlink" title="8. 一套可复用的海光 DCU Trace 分析流程"></a>8. 一套可复用的海光 DCU Trace 分析流程</h2><h3 id="第一步：固定请求"><a href="#第一步：固定请求" class="headerlink" title="第一步：固定请求"></a>第一步：固定请求</h3><p>选择一个能够覆盖目标 Prefill shape 和足够 Decode step 的单请求。</p><h3 id="第二步：记录覆盖范围"><a href="#第二步：记录覆盖范围" class="headerlink" title="第二步：记录覆盖范围"></a>第二步：记录覆盖范围</h3><p>明确写出：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">Prefill chunk 数量与 token 数</span><br><span class="line">Decode step 数量</span><br><span class="line">是否包含首次编译</span><br><span class="line">是否包含模型加载</span><br></pre></td></tr></table></figure><h3 id="第三步：按上下文分桶"><a href="#第三步：按上下文分桶" class="headerlink" title="第三步：按上下文分桶"></a>第三步：按上下文分桶</h3><p>至少分成：</p><ul><li>大块 Prefill；</li><li>尾部 Prefill；</li><li>Decode；</li><li>execute context 之外的采样和拷贝。</li></ul><h3 id="第四步：统计-kernel-count-和-total-time"><a href="#第四步：统计-kernel-count-和-total-time" class="headerlink" title="第四步：统计 kernel count 和 total time"></a>第四步：统计 kernel count 和 total time</h3><p>不要只保留 top-N 时间，还要保留 count 和平均耗时。</p><h3 id="第五步：除以-Decode-step"><a href="#第五步：除以-Decode-step" class="headerlink" title="第五步：除以 Decode step"></a>第五步：除以 Decode step</h3><p>把总调用数和总耗时换算成：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">launches/token</span><br><span class="line">ms/token</span><br></pre></td></tr></table></figure><h3 id="第六步：映射回模型层"><a href="#第六步：映射回模型层" class="headerlink" title="第六步：映射回模型层"></a>第六步：映射回模型层</h3><p>检查调用数能否由 MLP、GDN、Attention 和 lm_head 层数完整相加。</p><h3 id="第七步：计算物理上限"><a href="#第七步：计算物理上限" class="headerlink" title="第七步：计算物理上限"></a>第七步：计算物理上限</h3><p>对候选优化估算：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">maximum endpoint saving</span><br><span class="line">= current bucket time</span><br><span class="line">  × removable fraction</span><br></pre></td></tr></table></figure><h3 id="第八步：另起干净服务做-A-B"><a href="#第八步：另起干净服务做-A-B" class="headerlink" title="第八步：另起干净服务做 A&#x2F;B"></a>第八步：另起干净服务做 A&#x2F;B</h3><p>Profile 负责定位；无 profiler 的同环境端点负责确认真实吞吐、TTFT、TPOT 和输出一致性。</p><hr><h2 id="9-对海光-DCU-开发者的意义"><a href="#9-对海光-DCU-开发者的意义" class="headerlink" title="9. 对海光 DCU 开发者的意义"></a>9. 对海光 DCU 开发者的意义</h2><p>这份 trace 证明，在 gfx936 本地软件栈中，可以通过 PyTorch&#x2F;ROCm 兼容的 trace 链路同时观察：</p><ul><li>Tensile GEMM；</li><li>自定义 HIP kernel；</li><li>Triton kernel；</li><li>CUDAGraph replay；</li><li>Attention 与 GDN 路径；</li><li>Prefill 和 Decode 上下文。</li></ul><p>但数据量很大。真正有用的不是生成一个 trace 文件，而是把 60,124 个 kernel event 压缩成可验证的模型结构：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">176 次 LLMM1/token</span><br><span class="line">129 次 rocBLAS GEMV/token</span><br><span class="line">38.078 ms Linear/token</span><br><span class="line">88.33% Decode 占比</span><br></pre></td></tr></table></figure><p>当调用数、层数和时间全部闭合时，profile 才从“漂亮的时间线”变成可以指导海光 DCU 优化的工程证据。</p><hr><h2 id="10-结论"><a href="#10-结论" class="headerlink" title="10. 结论"></a>10. 结论</h2><p>从 235,964 条 trace event 中，本地最终还原出：</p><ul><li>三个 q&#x3D;4096 Prefill chunk；</li><li>一个 q&#x3D;1685 tail；</li><li>60 个 Decode step；</li><li>每 token 176 次 LLMM1；</li><li>每 token 129 次 rocBLAS GEMV；</li><li>Linear 合计 38.078 ms&#x2F;token；</li><li>Linear 占 Decode 88.33%。</li></ul><p>这篇文章的核心不是某个 kernel 排名，而是一种海光 DCU profile 方法：</p><blockquote><p>先按执行上下文切阶段，再用调用次数还原模型结构，最后把总时间换算成每 token 的物理预算。</p></blockquote><p>Profile 用来决定哪里值得做，端点 A&#x2F;B 用来决定做完是否真的有效。两种证据不能混用。</p><hr><p>本文数据来自一次有明确覆盖范围的本地 gfx936 诊断 trace。Profiler 运行具有额外开销，文中 profile 时间只用于算子归因和预算分析，不代表无 profiler 服务的最终吞吐。</p>]]>
    </content>
    <id>https://luanjiahao.qzz.io/2026/07/17/dcu-profile-kernel-to-token/</id>
    <link href="https://luanjiahao.qzz.io/2026/07/17/dcu-profile-kernel-to-token/"/>
    <published>2026-07-17T02:00:00.000Z</published>
    <summary>
      <![CDATA[<h2 id="开场"><a href="#开场" class="headerlink" title="开场"></a>开场</h2><p>在海光 DCU 上给大模型做 profile，最难的通常不是“能不能生成 trace”，而是 trace 生成以后该怎么看。</p>
<p>一次 Qwen3.5-27B BF16 本地采样共产生：</p>
<figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br]]>
    </summary>
    <title>海光 DCU Profile 实战：如何从六万个 GPU kernel 还原一枚 Token</title>
    <updated>2026-08-28T11:20:48.104Z</updated>
  </entry>
  <entry>
    <author>
      <name>YFGUG</name>
    </author>
    <category term="项目" scheme="https://luanjiahao.qzz.io/categories/%E9%A1%B9%E7%9B%AE/"/>
    <category term="海光 DCU" scheme="https://luanjiahao.qzz.io/tags/%E6%B5%B7%E5%85%89-DCU/"/>
    <category term="vLLM" scheme="https://luanjiahao.qzz.io/tags/vLLM/"/>
    <category term="ROCm" scheme="https://luanjiahao.qzz.io/tags/ROCm/"/>
    <category term="Kernel" scheme="https://luanjiahao.qzz.io/tags/Kernel/"/>
    <category term="性能工程" scheme="https://luanjiahao.qzz.io/tags/%E6%80%A7%E8%83%BD%E5%B7%A5%E7%A8%8B/"/>
    <content>
      <![CDATA[<p>我在海光 DCU 上修改 vLLM kernel 时，最浪费时间的一次并不是编译报错。</p><p>那次编译正常结束，服务能够启动，接口也能返回结果，性能测试甚至给出了一个看起来可以解释的变化。后来重新核对运行环境，我才发现一个更麻烦的问题：源码、构建产物和服务实际加载的文件并不是同一套状态。此前围绕性能做出的判断，只能全部作废。</p><p>这件事改变了我的调试顺序。现在每次改完 ROCm kernel，我不会立刻看吞吐，而是先回答一个更基础的问题：</p><blockquote><p>正在海光 DCU 上执行的，究竟是不是我刚刚修改、编译并准备测试的那份代码？</p></blockquote><p>本文记录的是我在 gfx936 架构海光 DCU 上实际使用过的一套 vLLM 增量编译与运行时验真方法。重点不是介绍某个更快的算子，而是把从 <code>.cu</code> 源码到真实 kernel dispatch 之间容易藏住旧状态的环节逐一拆开。</p><h2 id="实验环境"><a href="#实验环境" class="headerlink" title="实验环境"></a>实验环境</h2><p>本文涉及的实测环境如下。后文的时间和二进制信息只对这套环境负责，不外推为其他 DCU、DTK 或 vLLM 版本的固定结果。</p><table><thead><tr><th>项目</th><th>实测环境</th></tr></thead><tbody><tr><td>加速卡</td><td>海光 DCU，gfx936 架构，设备名显示为 <code>BW</code></td></tr><tr><td>DTK</td><td>26.04 系列环境</td></tr><tr><td>Python</td><td>3.10</td></tr><tr><td>PyTorch</td><td>2.10.0</td></tr><tr><td>构建工具</td><td>CMake、Ninja、hipify、ccache 4.5.1</td></tr><tr><td>vLLM ROCm 扩展</td><td><code>vllm._rocm_C</code></td></tr></tbody></table><p>在这份 vLLM 源码中，<code>_rocm_C</code> 由三个文件构成：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">csrc/rocm/torch_bindings.cpp</span><br><span class="line">csrc/rocm/skinny_gemms.cu</span><br><span class="line">csrc/rocm/attention.cu</span><br></pre></td></tr></table></figure><p><code>skinny_gemms.cu</code> 和 <code>attention.cu</code> 会先经过 hipify，再由 DTK 的编译器面向 <code>gfx936</code> 生成目标代码，最后与算子注册代码一起链接为 <code>_rocm_C.abi3.so</code>。</p><h2 id="一个-kernel-在运行前至少有六种“身份”"><a href="#一个-kernel-在运行前至少有六种“身份”" class="headerlink" title="一个 kernel 在运行前至少有六种“身份”"></a>一个 kernel 在运行前至少有六种“身份”</h2><p>只看源码目录，很容易误以为修改已经生效。实际链路要长得多：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><span class="line">skinny_gemms.cu</span><br><span class="line">        |</span><br><span class="line">        | hipify</span><br><span class="line">        v</span><br><span class="line">skinny_gemms.hip</span><br><span class="line">        |</span><br><span class="line">        | clang++ --offload-arch=gfx936</span><br><span class="line">        v</span><br><span class="line">cmake-build-rocm/_rocm_C.abi3.so</span><br><span class="line">        |</span><br><span class="line">        | cmake --install / 手工安装</span><br><span class="line">        v</span><br><span class="line">Python 环境中的 vllm/_rocm_C.abi3.so</span><br><span class="line">        |</span><br><span class="line">        | 服务进程加载</span><br><span class="line">        v</span><br><span class="line">/proc/&lt;pid&gt;/maps 中的已加载共享库</span><br><span class="line">        |</span><br><span class="line">        | shape、dtype、layout 等运行时条件</span><br><span class="line">        v</span><br><span class="line">本次请求实际选择的 kernel 分支</span><br></pre></td></tr></table></figure><p><img src="/img/dcu/dcu-kernel-identity-chain.png" alt="海光 DCU 上 vLLM kernel 从源码到真实 dispatch 的六层身份链路"></p><p>这里任何一层没有对齐，都会出现一种很危险的状态：程序能跑，但跑的不是你以为的实现。</p><p>因此，“编译成功”最多只能证明第三层生成了文件。它不能证明文件被安装到了当前 Python 环境，更不能证明已经启动的 vLLM 进程重新加载了它，最后也不能证明目标请求真的命中了新 dispatch。</p><h2 id="我实际跑通的增量编译"><a href="#我实际跑通的增量编译" class="headerlink" title="我实际跑通的增量编译"></a>我实际跑通的增量编译</h2><p>在已有构建目录的前提下，我修改 <code>csrc/rocm/skinny_gemms.cu</code> 后使用的是下面两条命令：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">cmake --build --preset rocm-release --target _rocm_C</span><br><span class="line">cmake --install cmake-build-rocm --component _rocm_C</span><br></pre></td></tr></table></figure><p>这套环境里有一个不太显眼的差异：仅放置 <code>CMakeUserPresets.json</code> 时，当前镜像的构建流程没有正确识别；换成实际使用的 <code>CMakePresets.json</code> 后才稳定工作。Preset 中至少要明确以下信息：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">VLLM_TARGET_DEVICE=rocm</span><br><span class="line">PYTORCH_ROCM_ARCH=gfx936</span><br><span class="line">ROCM_PATH=/opt/dtk</span><br><span class="line">CMAKE_HIP_COMPILER_LAUNCHER=ccache</span><br></pre></td></tr></table></figure><p>一次真实的增量构建日志可以压缩为下面几行：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">[1/4] ... hipify.py ... skinny_gemms.cu attention.cu</span><br><span class="line">skinny_gemms.cu -&gt; skinny_gemms.hip [ok]</span><br><span class="line">attention.cu -&gt; attention.hip [skipped, already hipified]</span><br><span class="line">Successfully preprocessed all matching files.</span><br><span class="line">Total number of unsupported CUDA function calls: 0</span><br><span class="line">Total number of replaced kernel launches: 19</span><br><span class="line"></span><br><span class="line">[2/3] clang++ ... --offload-arch=gfx936 ... skinny_gemms.hip</span><br><span class="line">[3/3] ... -o _rocm_C.abi3.so</span><br></pre></td></tr></table></figure><p>这几行比最后一句 <code>build success</code> 更有用。它们同时证明了四件事：</p><ol><li>修改过的 <code>.cu</code> 被重新 hipify；</li><li>未修改的 <code>attention.cu</code> 没有被无意义地重新转换；</li><li>编译命令确实以 <code>gfx936</code> 为目标架构；</li><li>最终发生了共享库重链接。</li></ol><p>同一次本地实验还记录了生成扩展的状态：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">size: 21018624 bytes</span><br><span class="line">sha256: 0e3280734c80bf7d33e101c04eecd9cdee0844feb3447b2141a3326ad6ef7549</span><br><span class="line">torch: 2.10.0</span><br><span class="line">LLMM1_registered: True</span><br></pre></td></tr></table></figure><p>这个哈希只用于标识当时那一份二进制，并不代表其他正确构建也应该得到相同哈希。</p><h3 id="共享目录会拖慢配置阶段"><a href="#共享目录会拖慢配置阶段" class="headerlink" title="共享目录会拖慢配置阶段"></a>共享目录会拖慢配置阶段</h3><p>我还对比过同一套源码在两种位置执行 CMake configure 的时间：</p><table><thead><tr><th>构建位置</th><th align="right">实测 configure 时间</th></tr></thead><tbody><tr><td>共享目录</td><td align="right">约 111 秒</td></tr><tr><td>容器本地 <code>/root</code></td><td align="right">13.5 秒</td></tr></tbody></table><p><img src="/img/dcu/dcu-cmake-configure-time.png" alt="共享目录与容器本地目录的 CMake configure 时间对比"></p><p>两者相差约 8.2 倍。这个结果并不说明 <code>/root</code> 在任何机器上都会快 8.2 倍，它说明的是：当源码树、CMake 探测和大量小文件操作都落在高延迟共享存储上时，配置阶段本身就可能成为迭代瓶颈。</p><p>我的做法是把临时源码 overlay 和 <code>cmake-build-rocm</code> 放在容器本地盘，编译完成后只把需要保存的日志、源码补丁和最终 <code>.so</code> 复制回持久化目录。这样既保留证据，也避免每次 configure 都在共享目录上重复做元数据访问。</p><h2 id="四种“看起来成功”的失败状态"><a href="#四种“看起来成功”的失败状态" class="headerlink" title="四种“看起来成功”的失败状态"></a>四种“看起来成功”的失败状态</h2><p>下面四种情况我都在实际调试中遇到过。它们之所以麻烦，是因为服务不一定报错。</p><h3 id="1-import-vllm-成功，但-ROCm-扩展并不存在"><a href="#1-import-vllm-成功，但-ROCm-扩展并不存在" class="headerlink" title="1. import vllm 成功，但 ROCm 扩展并不存在"></a>1. <code>import vllm</code> 成功，但 ROCm 扩展并不存在</h3><p>一次重新安装 wheel 后，Python 包恢复了，<code>import vllm</code> 也正常，但默认安装中没有 <code>_rocm_C.abi3.so</code>。如果只用 <code>import vllm</code> 验证环境，就会把“Python 包存在”误当成“自定义 ROCm 扩展已安装”。</p><p>更可靠的检查是直接导入扩展并确认目标算子完成注册：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line">python3 - &lt;&lt;<span class="string">&#x27;PY&#x27;</span></span><br><span class="line">import importlib.util</span><br><span class="line">import os</span><br><span class="line">import torch</span><br><span class="line"></span><br><span class="line">spec = importlib.util.find_spec(<span class="string">&quot;vllm._rocm_C&quot;</span>)</span><br><span class="line"><span class="built_in">print</span>(<span class="string">&quot;extension:&quot;</span>, None <span class="keyword">if</span> spec is None <span class="keyword">else</span> spec.origin)</span><br><span class="line"><span class="keyword">if</span> spec is None:</span><br><span class="line">    raise SystemExit(<span class="string">&quot;vllm._rocm_C is missing&quot;</span>)</span><br><span class="line"></span><br><span class="line">import vllm._rocm_C  <span class="comment"># noqa: F401</span></span><br><span class="line">op_name = os.environ.get(<span class="string">&quot;DCU_VERIFY_OP&quot;</span>, <span class="string">&quot;LLMM1&quot;</span>)</span><br><span class="line"><span class="built_in">print</span>(<span class="string">&quot;registered:&quot;</span>, op_name, hasattr(torch.ops._rocm_C, op_name))</span><br><span class="line">PY</span><br></pre></td></tr></table></figure><p>这里的 <code>LLMM1</code> 是我当时验证的算子名。换成其他工程时，应将 <code>DCU_VERIFY_OP</code> 设为自己真正需要测试的注册名。</p><h3 id="2-wheel-重装后，只复制了当前修改的一个文件"><a href="#2-wheel-重装后，只复制了当前修改的一个文件" class="headerlink" title="2. wheel 重装后，只复制了当前修改的一个文件"></a>2. wheel 重装后，只复制了当前修改的一个文件</h3><p>另一轮实验中，重装 wheel 会把 <code>site-packages</code> 恢复为 wheel 默认状态。我当时只复制了眼前正在修改的 Python 文件，却漏掉了候选实现继承的其他已修改文件。服务仍能启动，benchmark 也能完成，但那不是完整候选，结果没有比较价值。</p><p>从那以后，我不再按“这次手改了几个文件”决定同步范围，而是按完整源码差异决定：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">git diff --name-only &lt;基准版本&gt;...HEAD</span><br></pre></td></tr></table></figure><p>所有会影响运行时的差异文件都要安装、计算哈希并进入备份与恢复清单。对于 Python 文件，还应在启动服务前执行 <code>python -m py_compile</code>，避免把传输损坏、编码异常或残缺文件留到几十分钟后的模型加载阶段才发现。</p><h3 id="3-磁盘上的-so-更新了，旧进程仍在使用原映射"><a href="#3-磁盘上的-so-更新了，旧进程仍在使用原映射" class="headerlink" title="3. 磁盘上的 .so 更新了，旧进程仍在使用原映射"></a>3. 磁盘上的 <code>.so</code> 更新了，旧进程仍在使用原映射</h3><p>覆盖 <code>_rocm_C.abi3.so</code> 不会让已经运行的 Python 进程自动卸载旧共享库。更换二进制后必须完整重启服务；如果使用了 CUDAGraph，还应重新完成图捕获，不能沿用旧进程中的图状态。</p><p>我遇到过一次更隐蔽的情况：前一轮服务退出后，一个 <code>VLLM::EngineCore</code> 进程仍然存活并占用显存。下一轮服务虽然启动了，但环境已经不再干净。</p><p>在启动新服务前，我现在至少做两项只读检查：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">ps -eo pid,ppid,cmd | grep -E <span class="string">&#x27;vllm|VLLM::EngineCore&#x27;</span> | grep -v grep</span><br><span class="line">ss -ltnp | grep <span class="string">&#x27;:8000 &#x27;</span></span><br></pre></td></tr></table></figure><p>确认旧服务、EngineCore、benchmark driver 和监听端口都已清理，再通过正常的服务管理方式重新启动。启动后还可以查看目标进程的映射：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">PID=$(pgrep -n -f <span class="string">&#x27;VLLM::EngineCore&#x27;</span>)</span><br><span class="line">grep <span class="string">&#x27;_rocm_C.abi3.so&#x27;</span> <span class="string">&quot;/proc/<span class="variable">$&#123;PID&#125;</span>/maps&quot;</span></span><br></pre></td></tr></table></figure><p>它至少能回答“当前进程从哪个路径加载了扩展”。如果路径不符合预期，后面的性能数据没有继续分析的必要。</p><h3 id="4-文件名一样，二进制来源却不同"><a href="#4-文件名一样，二进制来源却不同" class="headerlink" title="4. 文件名一样，二进制来源却不同"></a>4. 文件名一样，二进制来源却不同</h3><p>有一次清理现场时，我记录到运行环境中的 <code>_rocm_C</code> 哈希前缀是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">7aee47576aa8...</span><br></pre></td></tr></table></figure><p>而准备作为对照的已知扩展哈希前缀是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">f2c5393af460...</span><br></pre></td></tr></table></figure><p>两个文件都叫 <code>_rocm_C.abi3.so</code>，目标算子也都能运行，但它们不是同一份二进制。没有哈希记录时，这种差异很容易被文件名掩盖。</p><p>我现在会同时记录构建目录和实际安装位置的哈希：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line">BUILD_SO=cmake-build-rocm/_rocm_C.abi3.so</span><br><span class="line">INSTALLED_SO=$(python3 - &lt;&lt;<span class="string">&#x27;PY&#x27;</span></span><br><span class="line">import importlib.util</span><br><span class="line">spec = importlib.util.find_spec(<span class="string">&quot;vllm._rocm_C&quot;</span>)</span><br><span class="line"><span class="keyword">if</span> spec is None:</span><br><span class="line">    raise SystemExit(1)</span><br><span class="line"><span class="built_in">print</span>(spec.origin)</span><br><span class="line">PY</span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="built_in">sha256sum</span> <span class="string">&quot;<span class="variable">$BUILD_SO</span>&quot;</span> <span class="string">&quot;<span class="variable">$INSTALLED_SO</span>&quot;</span></span><br><span class="line">cmp -s <span class="string">&quot;<span class="variable">$BUILD_SO</span>&quot;</span> <span class="string">&quot;<span class="variable">$INSTALLED_SO</span>&quot;</span> \</span><br><span class="line">  &amp;&amp; <span class="built_in">echo</span> <span class="string">&quot;binary match&quot;</span> \</span><br><span class="line">  || <span class="built_in">echo</span> <span class="string">&quot;binary mismatch&quot;</span></span><br></pre></td></tr></table></figure><h2 id="仅有哈希还不够：必须证明请求命中了新-dispatch"><a href="#仅有哈希还不够：必须证明请求命中了新-dispatch" class="headerlink" title="仅有哈希还不够：必须证明请求命中了新 dispatch"></a>仅有哈希还不够：必须证明请求命中了新 dispatch</h2><p>哈希一致只能证明服务加载了目标扩展，不能证明某个请求选择了新 kernel。vLLM 中的 dispatch 往往同时受 shape、dtype、layout、序列阶段和缓存配置影响。条件少命中一个，程序就可能安静地回退到原路径。</p><p>我的做法是在候选分支里保留一个受环境变量控制的诊断 marker，只在真正选择候选 kernel 的那一行打印：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> os.getenv(<span class="string">&quot;VLLM_DCU_KERNEL_DIAG&quot;</span>) == <span class="string">&quot;1&quot;</span>:</span><br><span class="line">    logger.warning(</span><br><span class="line">        <span class="string">&quot;DCU_KERNEL_DIAG selected=%s shape=%s dtype=%s&quot;</span>,</span><br><span class="line">        selected,</span><br><span class="line">        <span class="built_in">tuple</span>(x.shape),</span><br><span class="line">        x.dtype,</span><br><span class="line">    )</span><br></pre></td></tr></table></figure><p>marker 的关键不是“功能已启用”，而是 <code>selected=True</code> 必须来自实际 dispatch 结果。我的自动化 runner 会从服务日志提取 marker，并把“出现预期 shape、关键参数正确、selected&#x3D;True”作为 benchmark 有效的前置条件。</p><p>这一点对海光 DCU 上的 ROCm kernel 尤其重要。很多实验的 fallback 仍然是可用且性能不错的 vendor 或 Triton 路径，因此“服务没有报错”完全不能证明新 kernel 被执行。</p><h2 id="我现在使用的七道验真门"><a href="#我现在使用的七道验真门" class="headerlink" title="我现在使用的七道验真门"></a>我现在使用的七道验真门</h2><p>下面这张表是我最终固定下来的检查顺序。前一项没有通过，就不进入下一项。</p><table><thead><tr><th align="right">顺序</th><th>要证明什么</th><th>最低证据</th></tr></thead><tbody><tr><td align="right">1</td><td>源码状态完整</td><td>完整 diff 清单，不只看当前脏文件</td></tr><tr><td align="right">2</td><td>编译目标正确</td><td>日志出现 <code>--offload-arch=gfx936</code> 和目标源文件重编译</td></tr><tr><td align="right">3</td><td>安装的是新二进制</td><td>build <code>.so</code> 与 import 实际路径的 SHA256 一致</td></tr><tr><td align="right">4</td><td>算子注册成功</td><td>直接 import <code>vllm._rocm_C</code>，目标 op 为 <code>True</code></td></tr><tr><td align="right">5</td><td>进程状态干净</td><td>无残留 EngineCore、旧服务、旧 driver 和端口占用</td></tr><tr><td align="right">6</td><td>真实请求命中</td><td>服务日志出现带真实 shape 的 <code>selected=True</code> marker</td></tr><tr><td align="right">7</td><td>结果可用于比较</td><td>输出、请求数和失败数满足预先约定，再讨论性能</td></tr></tbody></table><p>这套流程看起来比“编译后直接跑 benchmark”多了几步，但多数检查只需要几秒。真正耗时的是跳过它们之后，花几个小时分析一组其实来自旧二进制、半套源码或 fallback 路径的数据。</p><h2 id="一段只读验真脚本"><a href="#一段只读验真脚本" class="headerlink" title="一段只读验真脚本"></a>一段只读验真脚本</h2><p>下面是我把常用检查压缩后的版本。它不修改文件，也不停止进程，适合在服务启动前后各执行一次。算子名可通过环境变量替换。</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">#!/usr/bin/env bash</span></span><br><span class="line"><span class="built_in">set</span> -euo pipefail</span><br><span class="line"></span><br><span class="line"><span class="built_in">export</span> DCU_VERIFY_OP=<span class="string">&quot;<span class="variable">$&#123;DCU_VERIFY_OP:-LLMM1&#125;</span>&quot;</span></span><br><span class="line"></span><br><span class="line">python3 - &lt;&lt;<span class="string">&#x27;PY&#x27;</span></span><br><span class="line">import importlib.util</span><br><span class="line">import os</span><br><span class="line">import torch</span><br><span class="line">import vllm</span><br><span class="line"></span><br><span class="line"><span class="built_in">print</span>(<span class="string">&quot;vllm:&quot;</span>, vllm.__file__)</span><br><span class="line">spec = importlib.util.find_spec(<span class="string">&quot;vllm._rocm_C&quot;</span>)</span><br><span class="line"><span class="built_in">print</span>(<span class="string">&quot;_rocm_C:&quot;</span>, None <span class="keyword">if</span> spec is None <span class="keyword">else</span> spec.origin)</span><br><span class="line"><span class="keyword">if</span> spec is None:</span><br><span class="line">    raise SystemExit(<span class="string">&quot;missing vllm._rocm_C&quot;</span>)</span><br><span class="line"></span><br><span class="line">import vllm._rocm_C  <span class="comment"># noqa: F401</span></span><br><span class="line">name = os.environ[<span class="string">&quot;DCU_VERIFY_OP&quot;</span>]</span><br><span class="line">ok = hasattr(torch.ops._rocm_C, name)</span><br><span class="line"><span class="built_in">print</span>(<span class="string">&quot;op:&quot;</span>, name, <span class="string">&quot;registered=&quot;</span>, ok)</span><br><span class="line"><span class="keyword">if</span> not ok:</span><br><span class="line">    raise SystemExit(<span class="string">&quot;target op is not registered&quot;</span>)</span><br><span class="line">PY</span><br><span class="line"></span><br><span class="line">SO=$(python3 - &lt;&lt;<span class="string">&#x27;PY&#x27;</span></span><br><span class="line">import importlib.util</span><br><span class="line"><span class="built_in">print</span>(importlib.util.find_spec(<span class="string">&quot;vllm._rocm_C&quot;</span>).origin)</span><br><span class="line">PY</span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="built_in">ls</span> -l <span class="string">&quot;<span class="variable">$SO</span>&quot;</span></span><br><span class="line"><span class="built_in">sha256sum</span> <span class="string">&quot;<span class="variable">$SO</span>&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="built_in">echo</span> <span class="string">&quot;--- active vLLM processes ---&quot;</span></span><br><span class="line">ps -eo pid,ppid,cmd | grep -E <span class="string">&#x27;vllm|VLLM::EngineCore&#x27;</span> | grep -v grep || <span class="literal">true</span></span><br></pre></td></tr></table></figure><p>如果构建目录中的 <code>.so</code>、Python 实际 import 的 <code>.so</code>、进程映射中的 <code>.so</code> 和 dispatch marker 能形成一条闭合证据链，我才会把后续结果写进实验结论。</p><h2 id="这套方法的边界"><a href="#这套方法的边界" class="headerlink" title="这套方法的边界"></a>这套方法的边界</h2><p>本文有三点需要明确：</p><ol><li><code>111 秒 -&gt; 13.5 秒</code> 是特定容器与共享存储条件下的观测，不是海光 DCU 的固定性能指标；</li><li><code>21018624</code> 字节和文中的 SHA256 属于一次具体构建，只用于说明如何记录二进制身份；</li><li>这套流程证明“测试对象正确”，并不自动证明 kernel 更快或数值正确，性能与正确性仍需单独验证。</li></ol><p>不过，对我而言，这一步已经成为海光 DCU kernel 开发中最值得保留的工程习惯。HIPify、DTK 编译器、CMake 构建树、Python 安装目录、长期运行的 vLLM 进程和运行时 dispatch，每一层都可能保留旧状态。把它们逐层验明，远比盯着最后一行 <code>build success</code> 更可靠。</p><p>我现在改完 kernel 后，第一反应不再是看数字涨了多少，而是先确认：源码对不对，二进制对不对，进程对不对，这个请求到底有没有走到它。多花的只是几分钟，省下的往往是一整轮错误实验。</p>]]>
    </content>
    <id>https://luanjiahao.qzz.io/2026/07/17/dcu-vllm-incremental-build-verification/</id>
    <link href="https://luanjiahao.qzz.io/2026/07/17/dcu-vllm-incremental-build-verification/"/>
    <published>2026-07-17T01:00:00.000Z</published>
    <summary>
      <![CDATA[<p>我在海光 DCU 上修改 vLLM kernel 时，最浪费时间的一次并不是编译报错。</p>
<p>那次编译正常结束，服务能够启动，接口也能返回结果，性能测试甚至给出了一个看起来可以解释的变化。后来重新核对运行环境，我才发现一个更麻烦的问题：源码、构建产物和服务实际加载的文件并不是同一套状态。此前围绕性能做出的判断，只能全部作废。</p>
<p>这件事改变了我的调试顺序。现在每次改完 ROCm kernel，我不会立刻看吞吐，而是先回答一个更基础的问题：</p>
<blockquote>
<p>正在海光 DCU 上执行的，究竟是不是我刚刚修改、编译并准备测试的那份代码？</p>
</b]]>
    </summary>
    <title>编译成功不等于生效：海光 DCU vLLM 增量编译验真</title>
    <updated>2026-08-28T11:20:48.104Z</updated>
  </entry>
  <entry>
    <author>
      <name>YFGUG</name>
    </author>
    <category term="项目日志" scheme="https://luanjiahao.qzz.io/categories/%E9%A1%B9%E7%9B%AE%E6%97%A5%E5%BF%97/"/>
    <category term="THU-BDC2026" scheme="https://luanjiahao.qzz.io/tags/THU-BDC2026/"/>
    <category term="机器学习" scheme="https://luanjiahao.qzz.io/tags/%E6%9C%BA%E5%99%A8%E5%AD%A6%E4%B9%A0/"/>
    <category term="回测" scheme="https://luanjiahao.qzz.io/tags/%E5%9B%9E%E6%B5%8B/"/>
    <category term="实验记录" scheme="https://luanjiahao.qzz.io/tags/%E5%AE%9E%E9%AA%8C%E8%AE%B0%E5%BD%95/"/>
    <category term="复盘" scheme="https://luanjiahao.qzz.io/tags/%E5%A4%8D%E7%9B%98/"/>
    <content>
      <![CDATA[<h2 id="先说结论"><a href="#先说结论" class="headerlink" title="先说结论"></a>先说结论</h2><p>THU-BDC2026 最值得记录的，不是某一次看起来很漂亮的收益率，而是那些把模型从“看起来有效”拉回“真的可验证”的实验。</p><p>这篇就是我的实验日志。它不追求把项目包装成一路顺风，反而专门把几次把结果越改越差的优化写下来，因为比赛里真正能留下来的经验，往往就藏在这些失败里。</p><table><thead><tr><th>时间</th><th>实验</th><th>结果</th><th>我的判断</th></tr></thead><tbody><tr><td>2026-05-31</td><td>单周期回测</td><td>最新数据集模型单周 +5.9533%</td><td>很亮，但周期太短，只能当线索</td></tr><tr><td>2026-06-04</td><td>修复数据泄露</td><td>夏普从 2.30 掉到 -0.49</td><td>难看，但终于诚实</td></tr><tr><td>2026-06-05</td><td>删除波动率特征</td><td>特征 289 -&gt; 227，夏普回到 0.40</td><td>做减法有效</td></tr><tr><td>2026-06-06</td><td>加资金流向因子</td><td>夏普 0.70，累计收益 12.33%</td><td>目前最像样的一版</td></tr><tr><td>2026-06-06</td><td>Top-80 特征筛选</td><td>训练快 57%，收益掉到 -7.47%</td><td>快不等于好</td></tr><tr><td>2026-06-07</td><td>行业覆盖修复</td><td>覆盖率 4.7% -&gt; 100%，夏普降到 0.18</td><td>数据完整不等于模型会用</td></tr></tbody></table><h2 id="为什么要写成日志"><a href="#为什么要写成日志" class="headerlink" title="为什么要写成日志"></a>为什么要写成日志</h2><p>这个项目一开始很容易让人上头：模型复杂、指标丰富、还有一些看起来很强的回测结果。</p><p>但量化项目最怕的就是“漂亮得不正常”。只要训练集切分、标签构造、特征窗口里有一点点未来信息，模型就会像突然开窍一样变强。问题是这种强不是真的强，它只是提前看了答案。</p><p>所以这篇文章的重点不是“我做出了一个多厉害的模型”，而是记录我怎么一步步把水分挤掉：</p><ul><li>先承认单周期结果不能当结论；</li><li>再修复训练和预测之间的时间隔离；</li><li>接着删掉可能依赖泄露的特征；</li><li>最后再看哪些因子还能留下真实增益。</li></ul><h2 id="早期单周期回测：很亮，但不能信太多"><a href="#早期单周期回测：很亮，但不能信太多" class="headerlink" title="早期单周期回测：很亮，但不能信太多"></a>早期单周期回测：很亮，但不能信太多</h2><p>archive 里有一份早期回测报告，只测了 2026-03-09 到 2026-03-13 这 5 个交易日。</p><p>当时结果看起来很好：</p><table><thead><tr><th>模型</th><th align="right">单周期收益</th></tr></thead><tbody><tr><td>原始数据集模型</td><td align="right">-0.7367%</td></tr><tr><td>最新数据集模型</td><td align="right">5.9533%</td></tr><tr><td>等权基准</td><td align="right">1.3114%</td></tr></tbody></table><p>如果只看这张表，很容易觉得方向已经对了。但 5 个交易日太短，运气成分很大。它更像一个提示：“这里可能有东西，值得继续查”，但不能证明模型稳定有效。</p><p>后来回头看，这个阶段最大的价值不是收益，而是提醒我必须把验证周期拉长，把回测流程做严。</p><h2 id="修复数据泄露：最痛，但最值"><a href="#修复数据泄露：最痛，但最值" class="headerlink" title="修复数据泄露：最痛，但最值"></a>修复数据泄露：最痛，但最值</h2><p>真正改变项目走向的是数据泄露修复。</p><p>原来的回测只保证训练样本日期早于回测日，但标签使用了未来 5 天开盘价。也就是说，靠近回测日的训练样本虽然日期在过去，标签却可能已经跨进未来窗口。</p><p>这类泄露非常隐蔽。代码看起来没问题，结果也很好看，但本质上模型已经偷看了答案。</p><p>修复后结果马上变难看：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">夏普:         -0.49</span><br><span class="line">累计组合:     -17.11%</span><br><span class="line">跑赢等权胜率: 44.8%</span><br><span class="line">最大回撤:     -31.17%</span><br></pre></td></tr></table></figure><p>这一步很打击人，但它是整条实验线里最重要的一步。因为从这里开始，后面的结果才有讨论价值。</p><h2 id="做减法：砍掉波动率特征"><a href="#做减法：砍掉波动率特征" class="headerlink" title="做减法：砍掉波动率特征"></a>做减法：砍掉波动率特征</h2><p>修复泄露之后，我开始怀疑一批波动率相关特征。</p><p>例如：</p><ul><li><code>boll_width</code></li><li><code>atr_pct</code></li><li><code>vol_20d</code></li><li>由这些指标衍生出来的聚合特征和横截面排名特征</li></ul><p>它们在旧版本里重要性很高，但旧版本本身有泄露。所以我不确定它们是真的有效，还是只是帮模型记住了错误验证里的捷径。</p><p>于是我做了一版删除实验：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">特征维度: 289 -&gt; 227</span><br><span class="line">夏普:     -0.49 -&gt; 0.40</span><br><span class="line">累计收益: -17.11% -&gt; 4.39%</span><br><span class="line">胜率:     44.8% -&gt; 51.7%</span><br></pre></td></tr></table></figure><p>这次结果反而变好。它给我的提醒很直接：特征不是越多越安全。尤其在金融数据里，有些特征看似信息量很大，其实只是把噪声和错误验证一起放大了。</p><h2 id="资金流向：少数真正带来增益的东西"><a href="#资金流向：少数真正带来增益的东西" class="headerlink" title="资金流向：少数真正带来增益的东西"></a>资金流向：少数真正带来增益的东西</h2><p>后面我加入了 Tushare 资金流向相关因子，主要包括三类：</p><ul><li>主力净流入率；</li><li>小单净流入率；</li><li>超大单占比。</li></ul><p>这一版是目前实验日志里比较像样的一版：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">夏普:         0.70</span><br><span class="line">累计组合:     12.33%</span><br><span class="line">年化收益:     29.90%</span><br><span class="line">跑赢等权胜率: 51.7%</span><br><span class="line">最大回撤:     -25.13%</span><br></pre></td></tr></table></figure><p>更关键的是，<code>主力净流入率_ma20_cs_rank</code> 进入了特征重要性 Top 20。</p><p>这让我觉得，资金行为信号可能比单纯堆价格形态更有价值。价格形态很容易过拟合，资金流向至少在这个窗口里提供了另一类信息。</p><h2 id="三个失败优化"><a href="#三个失败优化" class="headerlink" title="三个失败优化"></a>三个失败优化</h2><h3 id="Top-80-特征筛选"><a href="#Top-80-特征筛选" class="headerlink" title="Top-80 特征筛选"></a>Top-80 特征筛选</h3><p>我用 2023 年前的数据筛出重要性最高的 80 个特征，想减少噪声、加快训练。</p><p>训练速度确实变快了：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">训练时间: 116 分钟 -&gt; 50 分钟</span><br><span class="line">速度提升: 约 57%</span><br></pre></td></tr></table></figure><p>但结果变差了：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">累计收益: +12.33% -&gt; -7.47%</span><br><span class="line">夏普:     0.70 -&gt; -0.07</span><br></pre></td></tr></table></figure><p>这说明历史窗口里的重要性不一定能迁移到未来。金融数据不是静态数据集，市场状态一变，过去最重要的特征可能反而拖后腿。</p><h3 id="三分类标签"><a href="#三分类标签" class="headerlink" title="三分类标签"></a>三分类标签</h3><p>我还试过把股票粗分成三档：</p><ul><li>前 30%；</li><li>中间 40%；</li><li>后 30%。</li></ul><p>本意是让模型更关注“买入区”和“避雷区”，但实际效果很差，夏普掉到 -1.08。</p><p>原因大概是排序任务不能切得太粗。10 bins 标签虽然复杂一点，但保留了更多相对顺序信息，更适合每天从 300 只股票里选 Top 5。</p><h3 id="行业覆盖修复"><a href="#行业覆盖修复" class="headerlink" title="行业覆盖修复"></a>行业覆盖修复</h3><p>还有一次很反直觉的失败：行业覆盖修复。</p><p>原来行业覆盖率只有 4.7%，这肯定不合理，所以我把行业信息补到了 100%。从数据完整性的角度看，这一步绝对是正确的。</p><p>但模型表现却从夏普 0.70 降到 0.18，而且 <code>sector_id</code> 变成了最重要特征。</p><p>这说明模型可能不是“学到了行业”，而是“太相信行业”。行业信息需要更细的约束，比如降权、正则、分组验证，不能直接一股脑塞进去。</p><h2 id="最后留下的规则"><a href="#最后留下的规则" class="headerlink" title="最后留下的规则"></a>最后留下的规则</h2><p>这轮实验之后，我给自己留下了几条规则：</p><ol><li>单周期结果只能当线索，不能当结论。</li><li>回测第一步永远是查数据泄露。</li><li>特征重要性要按时间段看，不能拿一个历史窗口当真理。</li><li>做减法不丢人，能删掉噪声也是能力。</li><li>更完整的数据也需要被模型正确使用。</li><li>所有“看起来合理”的改动，最后都要让回测说话。</li></ol><h2 id="下一步怎么做"><a href="#下一步怎么做" class="headerlink" title="下一步怎么做"></a>下一步怎么做</h2><p>如果继续推进这个项目，我会优先做三件事：</p><ul><li>把滚动回测周期拉长，不再被单周收益带节奏；</li><li>对行业、市值、流动性这类强结构特征做分组验证；</li><li>保留 LightGBM LambdaRank 主线，把深度模型作为辅助实验，而不是主战场。</li></ul><p>这篇记录不构成任何投资建议。它更像一份比赛实验备忘录：把好看的结果拆开，把难看的结果留下，最后剩下的才可能是真的经验。</p>]]>
    </content>
    <id>https://luanjiahao.qzz.io/2026/06/13/thu-bdc2026-experiment-log/</id>
    <link href="https://luanjiahao.qzz.io/2026/06/13/thu-bdc2026-experiment-log/"/>
    <published>2026-06-13T03:20:00.000Z</published>
    <summary>一份公开版实验记录：从单周期高收益、数据泄露修复、特征删减，到资金流向因子和几次失败优化。</summary>
    <title>THU-BDC2026 实验日志：那些把结果越改越差的优化</title>
    <updated>2026-06-13T03:18:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>YFGUG</name>
    </author>
    <category term="项目" scheme="https://luanjiahao.qzz.io/categories/%E9%A1%B9%E7%9B%AE/"/>
    <category term="THU-BDC2026" scheme="https://luanjiahao.qzz.io/tags/THU-BDC2026/"/>
    <category term="LightGBM" scheme="https://luanjiahao.qzz.io/tags/LightGBM/"/>
    <category term="Transformer" scheme="https://luanjiahao.qzz.io/tags/Transformer/"/>
    <category term="LambdaRank" scheme="https://luanjiahao.qzz.io/tags/LambdaRank/"/>
    <category term="特征工程" scheme="https://luanjiahao.qzz.io/tags/%E7%89%B9%E5%BE%81%E5%B7%A5%E7%A8%8B/"/>
    <content>
      <![CDATA[<h2 id="为什么单独写一篇技术路线"><a href="#为什么单独写一篇技术路线" class="headerlink" title="为什么单独写一篇技术路线"></a>为什么单独写一篇技术路线</h2><p>上一篇更像比赛复盘，这一篇就专门把 THU-BDC2026 的技术路线理一遍。</p><p>这个项目从最早的深度模型，到后面的 LightGBM LambdaRank，中间不是简单的“换模型”，而是我对这个题目的理解发生了变化：股票排序任务不只是预测收益率，更像是在同一天的 300 只股票里做横截面比较。</p><p>所以后面我越来越关注三件事：</p><ul><li>每天的横截面排序；</li><li>训练和预测的时间隔离；</li><li>特征是否真的能穿过不同年份。</li></ul><h2 id="第一阶段：Transformer-横截面建模"><a href="#第一阶段：Transformer-横截面建模" class="headerlink" title="第一阶段：Transformer + 横截面建模"></a>第一阶段：Transformer + 横截面建模</h2><p>最早的设计是 StockTransformer。</p><p>输入用过去 30 个交易日的数据，每只股票有 72 个技术指标，整体组织成：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">[batch, 300只股票, 30天, 72特征]</span><br></pre></td></tr></table></figure><p>模型里有几个关键模块：</p><ul><li>TransformerEncoder：处理单只股票的时间序列；</li><li>FeatureAttention：聚合不同尺度的时序信息；</li><li>CrossStockAttention：让同一天的股票互相“看见”；</li><li>MoE Ranking：用多个专家头做排序打分；</li><li>Score Head：输出每只股票的最终分数。</li></ul><p>这个设计的优点是表达能力强，也比较符合“股票之间有相对关系”的直觉。缺点也很明显：训练慢、调参成本高、回测迭代不方便，而且验证方式稍微有问题，复杂模型会把问题放大。</p><h2 id="第二阶段：转向-LambdaRank"><a href="#第二阶段：转向-LambdaRank" class="headerlink" title="第二阶段：转向 LambdaRank"></a>第二阶段：转向 LambdaRank</h2><p>后面我把主要迭代切到了 LightGBM 的 <code>lambdarank</code>。</p><p>原因很实际：</p><p>第一，比赛目标本质上是 Top 5 排序，不一定非要预测一个绝对收益率。每天 300 只股票作为一个 group，用排序目标直接优化 NDCG@3 &#x2F; NDCG@5，会更贴近任务。</p><p>第二，LightGBM 训练快很多。一次滚动回测虽然还是要跑很久，但比深度模型更适合反复试错。</p><p>第三，树模型的特征重要性更容易检查。它不一定代表因果，但至少能让我发现模型是不是被某一类奇怪特征带偏。</p><h2 id="标签和分组"><a href="#标签和分组" class="headerlink" title="标签和分组"></a>标签和分组</h2><p>标签定义是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">(T+5 开盘价 - T+1 开盘价) / T+1 开盘价</span><br></pre></td></tr></table></figure><p>也就是说，模型预测的是从 T+1 到 T+5 的阶段收益。为了让不同交易日之间可比，我会按日期做横截面处理，再把同一天的股票作为一个排序组。</p><p>这个地方最容易出问题。</p><p>如果回测日是 <code>bt_date</code>，训练集不能只写成：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">processed[<span class="string">&#x27;日期&#x27;</span>] &lt; bt_date</span><br></pre></td></tr></table></figure><p>因为靠近 <code>bt_date</code> 的样本，它的 label 可能已经用到了未来开盘价。后面我改成至少隔开一段时间，避免训练集偷看到预测附近的信息。</p><p>这一步比换任何模型都重要。</p><h2 id="特征工程"><a href="#特征工程" class="headerlink" title="特征工程"></a>特征工程</h2><p>项目里用过的特征大概分几类：</p><table><thead><tr><th>类型</th><th>例子</th><th>作用</th></tr></thead><tbody><tr><td>趋势&#x2F;位置</td><td><code>ma_dev_60d</code>、<code>price_pos_20d</code></td><td>描述价格相对均线和区间的位置</td></tr><tr><td>K 线形态</td><td><code>lower_shadow_ratio</code>、<code>upper_shadow_ratio</code></td><td>描述日内买卖力量</td></tr><tr><td>量价关系</td><td><code>corr_pv_20</code>、<code>obv_ratio</code></td><td>描述成交量和价格变化关系</td></tr><tr><td>截面排名</td><td><code>*_cs_rank</code></td><td>在同一天 300 只股票里做相对比较</td></tr><tr><td>市值&#x2F;行业</td><td><code>cap_bin</code>、<code>sector_id</code></td><td>提供结构性信息</td></tr><tr><td>资金流向</td><td><code>主力净流入率</code>、<code>超大单占比</code></td><td>捕捉资金行为信号</td></tr></tbody></table><p>我后来对“截面排名”越来越有好感。因为股票比赛里，很多时候绝对值没有那么稳定，但同一天谁更强、谁更弱，反而更接近任务本身。</p><h2 id="几个关键版本"><a href="#几个关键版本" class="headerlink" title="几个关键版本"></a>几个关键版本</h2><table><thead><tr><th>版本</th><th>主要改动</th><th align="right">夏普</th><th align="right">累计收益</th></tr></thead><tbody><tr><td>修复泄露后基线</td><td>排除含未来信息的训练样本</td><td align="right">-0.49</td><td align="right">-17.11%</td></tr><tr><td>CutVol</td><td>砍掉波动率相关特征</td><td align="right">0.40</td><td align="right">4.39%</td></tr><tr><td>Tushare 增强</td><td>加资金流向和行业信息</td><td align="right">0.70</td><td align="right">12.33%</td></tr><tr><td>Top-80 特征筛选</td><td>只保留历史重要性前 80</td><td align="right">-0.07</td><td align="right">-7.47%</td></tr><tr><td>三分类标签</td><td>前 30% &#x2F; 中间 &#x2F; 后 30%</td><td align="right">-1.08</td><td align="right">-25.77%</td></tr><tr><td>行业覆盖修复</td><td>行业覆盖率 4.7% 到 100%</td><td align="right">0.18</td><td align="right">-0.48%</td></tr></tbody></table><p>这些数值都是阶段性回测结果，不是官方排名，也不是投资建议。</p><h2 id="最反直觉的地方"><a href="#最反直觉的地方" class="headerlink" title="最反直觉的地方"></a>最反直觉的地方</h2><p>这个项目里有几个结论挺反直觉。</p><p>第一，砍特征会变好。波动率特征在有数据泄露时很显眼，修复以后反而拖累模型。它提醒我：特征重要性不是永恒真理，它依赖验证方式。</p><p>第二，特征筛选会变差。Top-80 版本训练速度快了很多，但跨年份效果不行。金融数据里，过去重要的东西不一定在未来继续重要。</p><p>第三，修行业覆盖率也会变差。行业数据从 4.7% 修到 100%，按理说 metadata 更干净了，但模型把 <code>sector_id</code> 看得太重，结果反而下降。</p><p>这三个结果都说明一件事：量化模型不能靠直觉验收，必须靠严格回测验收。</p><h2 id="我现在更认可的路线"><a href="#我现在更认可的路线" class="headerlink" title="我现在更认可的路线"></a>我现在更认可的路线</h2><p>如果重新做一遍，我不会一上来就堆很复杂的深度结构。</p><p>我会先用 LightGBM LambdaRank 做一个干净、快、可解释的基线，把下面这些东西做扎实：</p><ul><li>日期分组和标签隔离；</li><li>滚动回测；</li><li>去最优月后的稳健性检查；</li><li>特征重要性漂移；</li><li>单次改动的 ablation；</li><li>预测结果的权重约束。</li></ul><p>等这些都稳定以后，再考虑把深度模型拿回来做融合。</p><p>这个项目最后让我更相信：比赛里真正高级的地方，不是模型名字听起来多厉害，而是你能不能证明自己的结果不是碰巧、不是偷看未来、不是被某个奇怪特征骗了。</p>]]>
    </content>
    <id>https://luanjiahao.qzz.io/2026/06/13/thu-bdc2026-model-iteration/</id>
    <link href="https://luanjiahao.qzz.io/2026/06/13/thu-bdc2026-model-iteration/"/>
    <published>2026-06-13T03:10:00.000Z</published>
    <summary>
      <![CDATA[<h2 id="为什么单独写一篇技术路线"><a href="#为什么单独写一篇技术路线" class="headerlink" title="为什么单独写一篇技术路线"></a>为什么单独写一篇技术路线</h2><p>上一篇更像比赛复盘，这一篇就专门把 THU-BDC2026 的技术路线理一遍。</p>
<p>这个项目从最早的深度模型，到后面的 LightGBM LambdaRank，中间不是简单的“换模型”，而是我对这个题目的理解发生了变化：股票排序任务不只是预测收益率，更像是在同一天的 300 只股票里做横截面比较。</p>
<p>所以后面我越来越关注三件事：</p>
<ul>
<li>]]>
    </summary>
    <title>THU-BDC2026 技术路线：从 Transformer 到 LightGBM 排序</title>
    <updated>2026-08-28T11:20:48.104Z</updated>
  </entry>
  <entry>
    <author>
      <name>YFGUG</name>
    </author>
    <category term="比赛复盘" scheme="https://luanjiahao.qzz.io/categories/%E6%AF%94%E8%B5%9B%E5%A4%8D%E7%9B%98/"/>
    <category term="THU-BDC2026" scheme="https://luanjiahao.qzz.io/tags/THU-BDC2026/"/>
    <category term="量化" scheme="https://luanjiahao.qzz.io/tags/%E9%87%8F%E5%8C%96/"/>
    <category term="机器学习" scheme="https://luanjiahao.qzz.io/tags/%E6%9C%BA%E5%99%A8%E5%AD%A6%E4%B9%A0/"/>
    <category term="回测" scheme="https://luanjiahao.qzz.io/tags/%E5%9B%9E%E6%B5%8B/"/>
    <category term="数据泄露" scheme="https://luanjiahao.qzz.io/tags/%E6%95%B0%E6%8D%AE%E6%B3%84%E9%9C%B2/"/>
    <content>
      <![CDATA[<h2 id="起因"><a href="#起因" class="headerlink" title="起因"></a>起因</h2><p>这次 THU-BDC2026，我手里其实留了三份项目目录：原版、archive、v2。看起来像三个项目，翻完之后发现更准确的说法是：这是同一个比赛项目的三段成长史。</p><p>赛题本身很直接：根据沪深 300 成分股的历史行情，对股票做排序，选出 Top 5 组成投资组合，再给它们分配权重。说白了，就是让模型回答一个很现实的问题：下一段时间里，哪几只股票更值得排在前面。</p><p>一开始我很兴奋，因为这个题目天然适合写得很“高级”：Transformer、CrossStockAttention、MoE、横截面标准化、Top 5 softmax 权重。关键词堆起来很漂亮，代码也确实能跑。</p><p>但后来我发现，金融比赛里最可怕的东西不是模型不够复杂，而是模型看起来太对了。</p><h2 id="第一版：看起来很猛"><a href="#第一版：看起来很猛" class="headerlink" title="第一版：看起来很猛"></a>第一版：看起来很猛</h2><p>最早的方案是深度模型路线。</p><p>我把过去 30 个交易日作为时间窗口，给每只股票计算 72 个技术指标，然后构造成一个四维张量：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">[batch, 股票数, 30天, 特征数]</span><br></pre></td></tr></table></figure><p>模型结构大概是：</p><ul><li>StockTransformer 建模时间序列；</li><li>CrossStockAttention 建模同一天不同股票之间的关系；</li><li>MoE Ranking 增加模型容量；</li><li>最后输出每只股票的排序分数，取 Top 5。</li></ul><p>这个方案从工程上看挺完整，甚至还有点“比赛味”。但做量化不能只看结构漂不漂亮，核心还是回测能不能经得住审。</p><h2 id="那个让我冷静下来的-bug"><a href="#那个让我冷静下来的-bug" class="headerlink" title="那个让我冷静下来的 bug"></a>那个让我冷静下来的 bug</h2><p>后面我在回测代码里发现了一个很严重的问题：训练数据过滤只用了预测日前的数据。</p><p>原来的逻辑类似这样：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">train_proc = processed[processed[<span class="string">&#x27;日期&#x27;</span>] &lt; bt_date].copy()</span><br></pre></td></tr></table></figure><p>问题在于，标签是用未来 5 天开盘价算的。也就是说，预测日前几天的样本虽然日期小于预测日，但它们的 label 里可能已经包含了预测日附近的未来价格信息。</p><p>修复后，我把训练样本和预测日之间隔开：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">train_proc = processed[</span><br><span class="line">    (processed[<span class="string">&#x27;日期&#x27;</span>] &lt; bt_date) &amp;</span><br><span class="line">    (processed[<span class="string">&#x27;日期&#x27;</span>] + pd.Timedelta(days=<span class="number">10</span>) &lt; bt_date)</span><br><span class="line">].copy()</span><br></pre></td></tr></table></figure><p>这一刀下去，结果非常难看。</p><table><thead><tr><th>指标</th><th align="right">修复前声称</th><th align="right">修复后回测</th></tr></thead><tbody><tr><td>夏普</td><td align="right">2.30</td><td align="right">-0.49</td></tr><tr><td>累计收益</td><td align="right">-</td><td align="right">-17.11%</td></tr><tr><td>跑赢等权胜率</td><td align="right">-</td><td align="right">44.8%</td></tr><tr><td>最大回撤</td><td align="right">-</td><td align="right">-31.17%</td></tr></tbody></table><p>最难受的不是亏损，而是我必须承认：之前那个漂亮结果不可信。</p><h2 id="重新开始做一个更诚实的版本"><a href="#重新开始做一个更诚实的版本" class="headerlink" title="重新开始做一个更诚实的版本"></a>重新开始做一个更诚实的版本</h2><p>修完数据泄露以后，我没有继续往模型上堆东西，而是先做减法。</p><p>我发现一些波动率相关特征在泄露存在时特别“有用”，但修复之后反而像噪声源。于是砍掉了 <code>vol_5d</code>、<code>vol_10d</code>、<code>vol_20d</code>、<code>boll_pctb</code>、<code>boll_width</code>、<code>atr_pct</code> 这些特征。</p><p>特征维度从 289 降到 227，回测反而变好了：</p><table><thead><tr><th>版本</th><th align="right">夏普</th><th align="right">累计收益</th><th align="right">胜率</th></tr></thead><tbody><tr><td>修复泄露后基线</td><td align="right">-0.49</td><td align="right">-17.11%</td><td align="right">44.8%</td></tr><tr><td>砍波动率特征</td><td align="right">0.40</td><td align="right">4.39%</td><td align="right">51.7%</td></tr></tbody></table><p>这一步给我的提醒很大：不是特征越多越好。有些特征在错误的验证方式下会显得特别聪明，但一旦验证变干净，它就露馅了。</p><h2 id="Tushare-增强版：比较像样的一版"><a href="#Tushare-增强版：比较像样的一版" class="headerlink" title="Tushare 增强版：比较像样的一版"></a>Tushare 增强版：比较像样的一版</h2><p>后面我加了 Tushare 的资金流向因子和申万行业分类，主要包括：</p><ul><li>主力净流入率；</li><li>小单净流入率；</li><li>超大单占比；</li><li>行业和市值相关特征。</li></ul><p>这版阶段性回测表现最好：</p><table><thead><tr><th>指标</th><th align="right">Tushare 增强版</th></tr></thead><tbody><tr><td>夏普</td><td align="right">0.70</td></tr><tr><td>累计收益</td><td align="right">12.33%</td></tr><tr><td>年化收益</td><td align="right">29.90%</td></tr><tr><td>跑赢等权胜率</td><td align="right">51.7%</td></tr><tr><td>最大回撤</td><td align="right">-25.13%</td></tr></tbody></table><p>其中 <code>主力净流入率_ma20_cs_rank</code> 进入了 Top 20 特征重要性，这说明资金流向类因子确实提供了一些额外信息。</p><p>但我也不想把这写成“终于成功了”。因为回测窗口只有 2024 年到 2026 年的一段滚动测试，而且去掉最好的月份后，收益稳定性会明显变弱。它是一个阶段性更好的版本，不是能拿来吹一辈子的神模型。</p><h2 id="那些没变好的尝试"><a href="#那些没变好的尝试" class="headerlink" title="那些没变好的尝试"></a>那些没变好的尝试</h2><p>这个项目后面还有几次很有意思的失败。</p><p>我试过 Top-80 特征筛选，训练时间确实从 116 分钟降到 50 分钟，但夏普从 0.70 掉到 -0.07。原因大概是 2023 年前的重要特征，不一定能迁移到 2024-2026 年。</p><p>我也试过三分类标签，把股票分成“前 30% &#x2F; 中间 40% &#x2F; 后 30%”。结果更差，夏普掉到 -1.08。后来想想也合理：股票排序任务需要保留尽量多的相对顺序信息，粗暴三分类反而把中间信息抹掉了。</p><p>还有一次我把行业覆盖率从 4.7% 修到 100%，听起来肯定是对的，但回测从 0.70 掉到 0.18。这个结果很反直觉，也挺真实：数据更完整，不代表模型一定更好。如果模型过度依赖行业特征，修 metadata 也可能把它带偏。</p><h2 id="最大的收获"><a href="#最大的收获" class="headerlink" title="最大的收获"></a>最大的收获</h2><p>这次比赛给我的最大教训不是某个模型结构，也不是某个指标，而是四个字：别骗自己。</p><p>金融机器学习里，最危险的是“看起来合理”。代码能跑、曲线好看、指标很高，这些都不能自动证明模型真的有预测能力。</p><p>真正重要的是：</p><ul><li>标签有没有偷看未来；</li><li>训练集和预测日有没有隔离；</li><li>回测窗口是不是太短；</li><li>最优月份是不是撑起了全部收益；</li><li>特征重要性是不是只是某个时间段的幻觉；</li><li>改动变好，是不是只是碰巧。</li></ul><p>这篇文章只记录比赛和回测复盘，不构成任何投资建议。</p><p>如果以后我再做类似项目，我会先把回测卫生、数据泄露检查、滚动验证这些东西放在第一位。模型可以慢慢变复杂，但验证方式一旦错了，后面所有努力都会被带到沟里。</p><p>这次最有价值的地方，可能就是我真的掉进去了一次，然后把它记录下来了。</p>]]>
    </content>
    <id>https://luanjiahao.qzz.io/2026/06/13/competition-review-notes/</id>
    <link href="https://luanjiahao.qzz.io/2026/06/13/competition-review-notes/"/>
    <published>2026-06-13T03:00:00.000Z</published>
    <summary>
      <![CDATA[<h2 id="起因"><a href="#起因" class="headerlink" title="起因"></a>起因</h2><p>这次 THU-BDC2026，我手里其实留了三份项目目录：原版、archive、v2。看起来像三个项目，翻完之后发现更准确的说法是：这是同一个比赛项目的三段成长史。</p>
<p>赛题本身很直接：根据沪深 300 成分股的历史行情，对股票做排序，选出 Top 5 组成投资组合，再给它们分配权重。说白了，就是让模型回答一个很现实的问题：下一段时间里，哪几只股票更值得排在前面。</p>
<p>一开始我很兴奋，因为这个题目天然适合写得很“高级”：Transfor]]>
    </summary>
    <title>THU-BDC2026 复盘：我差点被一个漂亮回测骗了</title>
    <updated>2026-08-28T11:20:48.100Z</updated>
  </entry>
  <entry>
    <author>
      <name>YFGUG</name>
    </author>
    <category term="项目" scheme="https://luanjiahao.qzz.io/categories/%E9%A1%B9%E7%9B%AE/"/>
    <category term="AI" scheme="https://luanjiahao.qzz.io/tags/AI/"/>
    <category term="Agent" scheme="https://luanjiahao.qzz.io/tags/Agent/"/>
    <category term="Claude Code" scheme="https://luanjiahao.qzz.io/tags/Claude-Code/"/>
    <category term="开发效率" scheme="https://luanjiahao.qzz.io/tags/%E5%BC%80%E5%8F%91%E6%95%88%E7%8E%87/"/>
    <content>
      <![CDATA[<h2 id="起因"><a href="#起因" class="headerlink" title="起因"></a>起因</h2><p>最近整理项目的时候，我顺手把小米 MIMO 开发者 Token 申请材料也翻出来了。</p><p>这份材料本来是为了说明我用 AI&#x2F;Agent 做过什么，但写完之后发现，它其实也挺适合当一篇复盘：AI 到底帮了我什么？又有哪些地方不能全交给它？</p><h2 id="AI-参与了哪些项目"><a href="#AI-参与了哪些项目" class="headerlink" title="AI 参与了哪些项目"></a>AI 参与了哪些项目</h2><p>主要是两类：</p><p>第一类是完整业务系统。</p><p>我用 AI 辅助做了校园宿舍管理系统和电商平台。前者是 Spring Boot + MyBatis + MySQL，后者是 Servlet + JSP + DBUtils 的传统 Java Web 项目。</p><p>第二类是技术实验。</p><p>比如 Spring AOP、JdbcTemplate + 声明式事务、Spring MVC 拦截器、三层架构这些小实验。它们不一定大，但很适合验证一个知识点到底有没有学明白。</p><h2 id="AI-最擅长的部分"><a href="#AI-最擅长的部分" class="headerlink" title="AI 最擅长的部分"></a>AI 最擅长的部分</h2><p>我感觉 AI 最适合处理那些”有规律、但很耗时间”的代码。</p><p>比如：</p><ul><li>Entity、DTO、VO 这种结构化类。</li><li>Controller&#x2F;Service&#x2F;Mapper 的基础模板。</li><li>MyBatis 动态 SQL 的第一版。</li><li>DAO 层查询代码。</li><li>前端一些重复的表单和异步请求片段。</li><li>配置文件和跨域、上传之类的查漏补缺。</li></ul><p>这些东西让人手写不是不会，而是容易疲劳。AI 先出第一版，我再去改业务逻辑，速度确实快很多。</p><p>我自己的体感是，规律性强的代码效率能提升 50%-60%。以前可能要两周慢慢磨的传统电商项目，用 AI 辅助后大概一周能跑出完整流程。</p><h2 id="但它不能替你负责"><a href="#但它不能替你负责" class="headerlink" title="但它不能替你负责"></a>但它不能替你负责</h2><p>AI 写出来的代码，最大的问题是”看起来很像对的”。</p><p>有时候它会给你一个很顺眼的 Controller，方法名、注解、返回值都像那么回事。但你真去跑，就会发现参数对不上、SQL 字段错了、前端传的名字和后端接的名字不一致。</p><p>所以我现在用 AI 写项目，会尽量把它当副驾驶：</p><ul><li>第一版可以让它出。</li><li>业务规则必须自己确认。</li><li>SQL 必须自己看。</li><li>安全边界必须自己补。</li><li>最后必须自己跑完整流程。</li></ul><p>AI 能减少重复劳动，但不能替你承担工程判断。</p><h2 id="对学习的影响"><a href="#对学习的影响" class="headerlink" title="对学习的影响"></a>对学习的影响</h2><p>最明显的变化是学习周期变短了。</p><p>从 Java SE 到 Servlet，再到 Spring、Spring Boot，如果完全手写慢慢摸，可能要拖三个月。AI 辅助后，我大概六周左右就把这条链路跑了一遍。</p><p>但这里有个前提：不能只复制粘贴。</p><p>如果只是让 AI 写完，然后自己不理解，那项目做完也没什么用。真正有用的是让它把”能跑的第一版”生成出来，然后自己去拆：</p><ul><li>这个接口为什么这么分层？</li><li>这个 SQL 为什么这样写？</li><li>这个配置是解决什么问题？</li><li>如果需求改了，我应该改哪里？</li></ul><p>这样 AI 才会变成学习加速器，而不是代码代写器。</p><h2 id="后续想做的事"><a href="#后续想做的事" class="headerlink" title="后续想做的事"></a>后续想做的事</h2><p>后面我想继续把 AI 用到更真实的工作流里。</p><p>比如：</p><ul><li>给宿舍管理系统接一个小程序前端。</li><li>让 AI 帮忙生成单元测试。</li><li>做自动化代码 review。</li><li>尝试把 Agent 接进后端流程里，处理一些多步骤任务。</li></ul><p>现在的感觉是，AI 不是让人不用学编程了，而是让”会问问题、会拆任务、会验收结果”变得更重要。</p><p>写代码这件事，正在从”我一行行敲”变成”我设计、我判断、我验收”。</p><p>这挺有意思的。</p>]]>
    </content>
    <id>https://luanjiahao.qzz.io/2026/06/13/ai-agent-mimo-application/</id>
    <link href="https://luanjiahao.qzz.io/2026/06/13/ai-agent-mimo-application/"/>
    <published>2026-06-13T02:00:00.000Z</published>
    <summary>
      <![CDATA[<h2 id="起因"><a href="#起因" class="headerlink" title="起因"></a>起因</h2><p>最近整理项目的时候，我顺手把小米 MIMO 开发者 Token 申请材料也翻出来了。</p>
<p>这份材料本来是为了说明我用 AI&#x2F;Agent 做过什么，但写完之后发现，它其实也挺适合当一篇复盘：AI 到底帮了我什么？又有哪些地方不能全交给它？</p>
<h2 id="AI-参与了哪些项目"><a href="#AI-参与了哪些项目" class="headerlink" title="AI 参与了哪些项目"></a>AI 参与了哪些项目</h]]>
    </summary>
    <title>小米 MIMO Token 申请：我用 AI/Agent 写项目的真实体感</title>
    <updated>2026-08-28T11:20:48.100Z</updated>
  </entry>
  <entry>
    <author>
      <name>YFGUG</name>
    </author>
    <category term="项目" scheme="https://luanjiahao.qzz.io/categories/%E9%A1%B9%E7%9B%AE/"/>
    <category term="Java" scheme="https://luanjiahao.qzz.io/tags/Java/"/>
    <category term="Servlet" scheme="https://luanjiahao.qzz.io/tags/Servlet/"/>
    <category term="JSP" scheme="https://luanjiahao.qzz.io/tags/JSP/"/>
    <category term="电商" scheme="https://luanjiahao.qzz.io/tags/%E7%94%B5%E5%95%86/"/>
    <content>
      <![CDATA[<h2 id="起因"><a href="#起因" class="headerlink" title="起因"></a>起因</h2><p>现在再写 Servlet + JSP，好像有点复古。</p><p>前端早就 React、Vue 满天飞，后端也基本都是 Spring Boot 起步。但我还是觉得，亲手用 Servlet 和 JSP 写一个电商 demo，很适合补 Web 基本功。</p><p>因为它没有那么多框架帮你藏细节。请求怎么进来，Session 怎么存，页面怎么跳，DAO 怎么查数据库，都得自己写。</p><p>项目在这里：<a href="https://github.com/yfgug/project">yfgug&#x2F;project</a></p><h2 id="功能大概长这样"><a href="#功能大概长这样" class="headerlink" title="功能大概长这样"></a>功能大概长这样</h2><p>这个电商平台不是玩具页面，基本购物流程是完整的：</p><ul><li>用户模块：注册、登录、注销、改密、修改收货地址。</li><li>商品模块：分类浏览、分页查询、关键词搜索、商品详情。</li><li>推荐模块：滚动横幅、热门推荐、新品推荐。</li><li>购物车：基于 Session 保存购物车，异步加入商品。</li><li>订单模块：购物车结算、订单生成、支付方式选择。</li></ul><p>技术上用的是传统 Java Web：Servlet、JSP、JSTL、Filter、DAO、Service。数据库访问用了 Apache DBUtils，连接池配置里实际使用的是 Druid，也保留了传统依赖说明。</p><h2 id="Session-购物车"><a href="#Session-购物车" class="headerlink" title="Session 购物车"></a>Session 购物车</h2><p>购物车这块挺能体现传统 Web 项目的味道。</p><p>用户点”加入购物车”时，前端请求 <code>goods_buy</code>。后端从 Session 里拿 <code>Order</code> 对象，如果没有就新建一个，再根据商品 id 查库存，库存足够就加入购物车。</p><p>这段逻辑不复杂，但它把几个基础点串起来了：</p><ul><li>Session 不是抽象概念，它真的可以存当前用户的临时状态。</li><li>Servlet 处理的是一次请求，但购物车需要跨请求保留。</li><li>后端不能相信前端，加入购物车前还是要查库存。</li></ul><p>以前我只知道 Session 是会话。写完之后才更具体地理解，它就是很多传统 Web 应用维持状态的关键。</p><h2 id="DAO-层也有坑"><a href="#DAO-层也有坑" class="headerlink" title="DAO 层也有坑"></a>DAO 层也有坑</h2><p>不用 ORM 的时候，SQL 会离你很近。</p><p>DBUtils 的 <code>QueryRunner</code> 能少写很多样板代码，<code>BeanHandler</code>、<code>MapListHandler</code> 也能把结果集处理得舒服一点。但 SQL 本身还是要自己负责。</p><p>比如分页查询，不能只想着页面显示几条，还要算：</p><ul><li>当前第几页。</li><li>每页多少条。</li><li>总记录数。</li><li>总页数。</li><li><code>limit</code> 从哪里开始。</li></ul><p>这些东西看着琐碎，但电商列表页离不开。</p><h2 id="为什么这种项目还有意义"><a href="#为什么这种项目还有意义" class="headerlink" title="为什么这种项目还有意义"></a>为什么这种项目还有意义</h2><p>这个项目写完之后，我更能理解 Spring Boot 为什么舒服。</p><p>不是因为 Servlet&#x2F;JSP 不能用，而是因为传统 Java Web 里太多事情需要你亲自管理：</p><ul><li>编码过滤器。</li><li>路由映射。</li><li>Session 状态。</li><li>JSP 页面跳转。</li><li>DAO 手写 SQL。</li><li>依赖包手动放到 <code>WEB-INF/lib</code>。</li></ul><p>写一遍之后，再去用 Spring MVC、Spring Boot，就不会觉得那些自动配置是魔法。它们只是把这些老活儿包装好了。</p><h2 id="最后"><a href="#最后" class="headerlink" title="最后"></a>最后</h2><p>这个电商平台不算现代，但很适合练手。</p><p>它让我知道一个 Web 应用不是只有页面，也不是只有数据库。真正的项目是一条完整链路：用户点按钮，请求进入后端，后端处理状态和数据，再把结果返回到页面。</p><p>这条链路走通了，后面学什么框架都轻松一点。</p>]]>
    </content>
    <id>https://luanjiahao.qzz.io/2026/06/13/servlet-jsp-ecommerce/</id>
    <link href="https://luanjiahao.qzz.io/2026/06/13/servlet-jsp-ecommerce/"/>
    <published>2026-06-13T01:30:00.000Z</published>
    <summary>
      <![CDATA[<h2 id="起因"><a href="#起因" class="headerlink" title="起因"></a>起因</h2><p>现在再写 Servlet + JSP，好像有点复古。</p>
<p>前端早就 React、Vue 满天飞，后端也基本都是 Spring Boot 起步。但我还是觉得，亲手用 Servlet 和 JSP 写一个电商 demo，很适合补 Web 基本功。</p>
<p>因为它没有那么多框架帮你藏细节。请求怎么进来，Session 怎么存，页面怎么跳，DAO 怎么查数据库，都得自己写。</p>
<p>项目在这里：<a href="https://github.c]]>
    </summary>
    <title>用 Servlet 和 JSP 写电商平台：有点古早，但真的练基本功</title>
    <updated>2026-08-28T11:20:48.104Z</updated>
  </entry>
  <entry>
    <author>
      <name>YFGUG</name>
    </author>
    <category term="项目" scheme="https://luanjiahao.qzz.io/categories/%E9%A1%B9%E7%9B%AE/"/>
    <category term="Spring Boot" scheme="https://luanjiahao.qzz.io/tags/Spring-Boot/"/>
    <category term="MyBatis" scheme="https://luanjiahao.qzz.io/tags/MyBatis/"/>
    <category term="MySQL" scheme="https://luanjiahao.qzz.io/tags/MySQL/"/>
    <category term="后端" scheme="https://luanjiahao.qzz.io/tags/%E5%90%8E%E7%AB%AF/"/>
    <content>
      <![CDATA[<h2 id="起因"><a href="#起因" class="headerlink" title="起因"></a>起因</h2><p>校园宿舍管理系统听起来很像课程设计。</p><p>学生信息、宿舍号、报修工单、公告管理，这些功能都很常见。刚开始我也觉得它就是一个 CRUD 项目，写几个接口、连个数据库、页面能点就行。</p><p>但真正写完之后才发现，CRUD 只是最外面那层壳。只要你稍微把功能做完整一点，就会遇到很多实际问题。</p><p>项目在这个仓库里：<a href="https://github.com/yfgug/project">yfgug&#x2F;project</a></p><h2 id="做了哪些功能"><a href="#做了哪些功能" class="headerlink" title="做了哪些功能"></a>做了哪些功能</h2><p>这个系统主要有几个模块：</p><ul><li>学生管理：增删改查、多条件搜索、批量删除、头像上传。</li><li>报修工单：提交、处理中、已解决、删除。</li><li>公告管理：发布、编辑、删除。</li><li>登录保护：连续输错密码后短时间锁定账号。</li><li>文件上传：限制后缀、限制大小、UUID 重命名。</li></ul><p>技术栈是 Spring Boot 2.5 + MyBatis + MySQL。后端用 RESTful API，返回值统一包了一层 <code>Result&lt;T&gt;</code>，配置放在 YAML 里，数据库连接池用 HikariCP。</p><h2 id="最先踩到的是查询"><a href="#最先踩到的是查询" class="headerlink" title="最先踩到的是查询"></a>最先踩到的是查询</h2><p>学生管理里最常见的需求就是搜索。</p><p>只按姓名搜很简单，只按宿舍号搜也很简单。但真实页面里经常是组合条件：姓名、性别、宿舍号、入住日期范围，用户想填哪个就填哪个。</p><p>如果每种组合都写一条 SQL，很快就会变成灾难。</p><p>后来我用 MyBatis 动态 SQL 来处理：</p><ul><li>有姓名就拼姓名条件。</li><li>有性别就拼性别条件。</li><li>有宿舍号就拼宿舍号条件。</li><li>有日期范围就拼日期范围。</li><li>批量删除用 <code>&lt;foreach&gt;</code> 展开 id 列表。</li></ul><p>这件事让我第一次比较直观地感觉到，框架不是为了炫技，是真的能救命。</p><h2 id="文件上传比想象中麻烦"><a href="#文件上传比想象中麻烦" class="headerlink" title="文件上传比想象中麻烦"></a>文件上传比想象中麻烦</h2><p>头像上传一开始看起来也很简单：前端传文件，后端存起来，再把路径写进数据库。</p><p>但问题很快就来了：</p><ul><li>用户传的文件是不是图片？</li><li>文件太大怎么办？</li><li>两个人上传同名文件会不会覆盖？</li><li>上传目录怎么映射成可访问路径？</li></ul><p>最后的处理方式是：</p><ul><li>只允许 <code>jpg/jpeg/png/gif/bmp/webp</code>。</li><li>限制 2MB 大小。</li><li>用 UUID 重命名。</li><li>单独做上传配置和资源映射。</li></ul><p>这些细节不写，功能也许能跑。但一旦真的给别人用，就很容易出问题。</p><h2 id="登录防暴力破解"><a href="#登录防暴力破解" class="headerlink" title="登录防暴力破解"></a>登录防暴力破解</h2><p>这个功能不复杂，但我觉得挺有意思。</p><p>我用 <code>ConcurrentHashMap</code> 记录登录失败次数。同一个账号连续输错 3 次，就锁定 5 秒。</p><p>这当然不是专业安全方案，也不能替代验证码、限流、审计日志这些东西。但对一个练习项目来说，它至少让我开始思考”接口不能只考虑正常路径”。</p><p>以前写登录接口，我只会想密码对不对。现在会多想一步：如果有人一直试密码怎么办？</p><h2 id="写完之后的感觉"><a href="#写完之后的感觉" class="headerlink" title="写完之后的感觉"></a>写完之后的感觉</h2><p>这个项目最大的价值，不是它有多少功能，而是它把后端常见的几件事都串了一遍：</p><ul><li>接收请求。</li><li>校验参数。</li><li>调用业务层。</li><li>访问数据库。</li><li>处理文件。</li><li>返回统一结果。</li><li>让前端页面真的能用。</li></ul><p>写之前我觉得 Spring Boot 是一堆注解。写完之后再看，它其实是在帮我把这些重复但重要的工作组织起来。</p><p>项目不大，但它让我对”一个后台系统应该长什么样”有了更清楚的感觉。</p>]]>
    </content>
    <id>https://luanjiahao.qzz.io/2026/06/13/dormitory-system-springboot/</id>
    <link href="https://luanjiahao.qzz.io/2026/06/13/dormitory-system-springboot/"/>
    <published>2026-06-13T01:00:00.000Z</published>
    <summary>
      <![CDATA[<h2 id="起因"><a href="#起因" class="headerlink" title="起因"></a>起因</h2><p>校园宿舍管理系统听起来很像课程设计。</p>
<p>学生信息、宿舍号、报修工单、公告管理，这些功能都很常见。刚开始我也觉得它就是一个 CRUD 项目，写几个接口、连个数据库、页面能点就行。</p>
<p>但真正写完之后才发现，CRUD 只是最外面那层壳。只要你稍微把功能做完整一点，就会遇到很多实际问题。</p>
<p>项目在这个仓库里：<a href="https://github.com/yfgug/project">yfgug&#x2F;project]]>
    </summary>
    <title>校园宿舍管理系统：一个 CRUD 项目也能写出很多坑</title>
    <updated>2026-08-28T11:20:48.104Z</updated>
  </entry>
  <entry>
    <author>
      <name>YFGUG</name>
    </author>
    <category term="项目" scheme="https://luanjiahao.qzz.io/categories/%E9%A1%B9%E7%9B%AE/"/>
    <category term="Java" scheme="https://luanjiahao.qzz.io/tags/Java/"/>
    <category term="Spring" scheme="https://luanjiahao.qzz.io/tags/Spring/"/>
    <category term="项目复盘" scheme="https://luanjiahao.qzz.io/tags/%E9%A1%B9%E7%9B%AE%E5%A4%8D%E7%9B%98/"/>
    <category term="AI辅助开发" scheme="https://luanjiahao.qzz.io/tags/AI%E8%BE%85%E5%8A%A9%E5%BC%80%E5%8F%91/"/>
    <content>
      <![CDATA[<h2 id="起因"><a href="#起因" class="headerlink" title="起因"></a>起因</h2><p>之前学 Java 的时候，我总有一种很奇怪的感觉：语法能看懂，视频也能跟着敲，但真要自己做一个东西，就不知道从哪开始。</p><p>所以后来我干脆把一堆练习和小项目放到一个仓库里，从 Java 基础、JDBC、Servlet&#x2F;JSP，一直堆到 Spring、Spring MVC、Spring Boot。看起来有点乱，但它刚好记录了我从”会写代码”到”能把一个业务系统跑起来”的过程。</p><p>项目地址：<a href="https://github.com/yfgug/project">yfgug&#x2F;project</a></p><h2 id="这个仓库里有什么"><a href="#这个仓库里有什么" class="headerlink" title="这个仓库里有什么"></a>这个仓库里有什么</h2><p>仓库主要分成四块：</p><table><thead><tr><th>目录</th><th>内容</th><th>技术栈</th></tr></thead><tbody><tr><td><code>java-basics/</code></td><td>Java 基础和 JDBC 练习</td><td>JDBC、PreparedStatement</td></tr><tr><td><code>ecommerce-platform/</code></td><td>一个传统电商平台 demo</td><td>Servlet、JSP、DBUtils、连接池</td></tr><tr><td><code>spring-experiments/</code></td><td>Spring 生态实验合集</td><td>Spring、Spring MVC、AOP、事务</td></tr><tr><td><code>dormitory-system/</code></td><td>校园宿舍管理系统</td><td>Spring Boot、MyBatis、MySQL</td></tr></tbody></table><p>这不是那种一上来就微服务、网关、消息队列全家桶的项目。它更像一条学习路线：先把数据库连明白，再把 Web 请求跑通，然后理解分层，最后再用 Spring Boot 把这些东西整合起来。</p><h2 id="为什么我觉得它有用"><a href="#为什么我觉得它有用" class="headerlink" title="为什么我觉得它有用"></a>为什么我觉得它有用</h2><p>很多教程会把知识点拆得很碎：今天讲 JDBC，明天讲 Servlet，后天讲 Spring Bean。单个知识点都不难，但真正困难的是把它们串起来。</p><p>比如一个最普通的”查询学生列表”，背后其实有一整条链路：</p><ol><li>页面发请求。</li><li>Controller 接参数。</li><li>Service 处理业务规则。</li><li>Mapper&#x2F;DAO 查数据库。</li><li>返回统一格式的数据。</li><li>前端再把结果渲染出来。</li></ol><p>这条链路走通以后，再看 Spring 里那些注解就没那么玄学了。<code>@Controller</code>、<code>@Service</code>、<code>@Mapper</code> 不是为了显得高级，它们只是把不同职责放到不同位置。</p><h2 id="CRUD-不丢人"><a href="#CRUD-不丢人" class="headerlink" title="CRUD 不丢人"></a>CRUD 不丢人</h2><p>以前我会觉得 CRUD 项目很普通，没什么好写的。</p><p>后来发现，CRUD 其实一点都不简单。增删改查只是表面，真正麻烦的是各种边界：</p><ul><li>查询要不要支持多条件组合？</li><li>删除要不要支持批量？</li><li>文件上传要不要限制格式和大小？</li><li>登录失败太多次要不要锁一下？</li><li>返回给前端的数据格式要不要统一？</li><li>页面刷新之后 Session 里的购物车还在不在？</li></ul><p>这些东西不会出现在”Hello World”里，但会出现在每个真实一点的项目里。</p><h2 id="AI-在里面做了什么"><a href="#AI-在里面做了什么" class="headerlink" title="AI 在里面做了什么"></a>AI 在里面做了什么</h2><p>这些项目里有不少代码是 AI 辅助生成的，尤其是模板感很强的部分：实体类、Controller、Service、Mapper、DAO、一些前端交互片段。</p><p>但我现在越来越觉得，AI 写代码不是”替你完成项目”，更像是”把你从重复劳动里拽出来”。</p><p>它可以帮我快速生成第一版，但最后还是得自己看：</p><ul><li>SQL 有没有查错表？</li><li>参数有没有校验？</li><li>文件上传有没有安全问题？</li><li>业务流程能不能真的走通？</li><li>代码只是能跑，还是以后也能改？</li></ul><p>这一步不能省。不然项目看着很完整，里面可能全是坑。</p><h2 id="最后"><a href="#最后" class="headerlink" title="最后"></a>最后</h2><p>这个仓库不算高级，但对我很重要。</p><p>它让我确认了一件事：学后端不是背框架名，也不是堆技术栈，而是把数据、请求、业务和页面之间的关系一遍遍跑通。</p><p>从 Java SE 到 Spring Boot，中间真正的变化不是代码变短了，而是我开始知道一个项目应该怎么拆、怎么连、怎么交付。</p><p>这大概就是练项目的意义。</p>]]>
    </content>
    <id>https://luanjiahao.qzz.io/2026/06/13/java-projects-roadmap/</id>
    <link href="https://luanjiahao.qzz.io/2026/06/13/java-projects-roadmap/"/>
    <published>2026-06-13T00:30:00.000Z</published>
    <summary>
      <![CDATA[<h2 id="起因"><a href="#起因" class="headerlink" title="起因"></a>起因</h2><p>之前学 Java 的时候，我总有一种很奇怪的感觉：语法能看懂，视频也能跟着敲，但真要自己做一个东西，就不知道从哪开始。</p>
<p>所以后来我干脆把一堆练习和小项目放到一个仓库里，从 Java 基础、JDBC、Servlet&#x2F;JSP，一直堆到 Spring、Spring MVC、Spring Boot。看起来有点乱，但它刚好记录了我从”会写代码”到”能把一个业务系统跑起来”的过程。</p>
<p>项目地址：<a href="https://gi]]>
    </summary>
    <title>把 Java 项目堆起来之后，我终于看懂了 Spring</title>
    <updated>2026-08-28T11:20:48.104Z</updated>
  </entry>
  <entry>
    <author>
      <name>YFGUG</name>
    </author>
    <category term="项目" scheme="https://luanjiahao.qzz.io/categories/%E9%A1%B9%E7%9B%AE/"/>
    <category term="Rust" scheme="https://luanjiahao.qzz.io/tags/Rust/"/>
    <category term="Tauri" scheme="https://luanjiahao.qzz.io/tags/Tauri/"/>
    <category term="QQ" scheme="https://luanjiahao.qzz.io/tags/QQ/"/>
    <category term="桌面应用" scheme="https://luanjiahao.qzz.io/tags/%E6%A1%8C%E9%9D%A2%E5%BA%94%E7%94%A8/"/>
    <content>
      <![CDATA[<h2 id="起因"><a href="#起因" class="headerlink" title="起因"></a>起因</h2><p>事情是这样的。</p><p>我有个习惯，每隔一段时间会把 QQ 聊天记录导出来备份。不是为了什么，就是怕哪天号没了，聊天记录也没了。</p><p>之前一直用一个叫 QQFlow 的工具，Electron + Python 写的，能用。但有两个问题：一是软件太大了，安装包 188MB；二是打开大数据库的时候卡得要命，190MB 的数据库要等好几分钟。</p><p>有天晚上我又在等它加载，看着进度条一动不动，突然想：要不用 Rust 重写一个？</p><h2 id="其实没那么难"><a href="#其实没那么难" class="headerlink" title="其实没那么难"></a>其实没那么难</h2><p>说”重写”有点高估我了。</p><p>我做的事情更像是”整合”。核心的思路是参考了 <a href="https://github.com/hicccc77/WeFlow">WeFlow</a> 这个微信聊天记录导出工具的架构——用一个内存缓存把所有消息一次性加载进来，后续查询直接走内存，不再碰数据库。</p><p>具体的实现是这样的：</p><ol><li><p>密钥提取：用 Windows Debug API 注入 QQ 进程，从内存里找到 SQLCipher 的加密密钥。这部分代码网上有现成的，我改了改就用了。</p></li><li><p>数据库解密：用 rusqlite + bundled-sqlcipher，流式复制一份解密后的数据库到临时目录。只复制一次，后续直接读缓存。</p></li><li><p>消息加载：<code>SELECT *</code> 全表扫描，把所有消息按群号&#x2F;私聊对象分组存到 HashMap 里。190MB 数据库大概 30-60 秒，之后所有查询都是 O(1)。</p></li><li><p>前端：React + TypeScript + Vite，抄了几个开源项目的样式，拼拼凑凑。</p></li></ol><p>说实话，我没写多少原创代码。大部分时间都在调试、在踩坑、在看报错日志。</p><h2 id="踩过的坑"><a href="#踩过的坑" class="headerlink" title="踩过的坑"></a>踩过的坑</h2><h3 id="坑一：死锁"><a href="#坑一：死锁" class="headerlink" title="坑一：死锁"></a>坑一：死锁</h3><p>第一个版本有个很诡异的 bug：点击群聊分析，永远显示”正在加载…”。</p><p>调试了两天才发现是死锁。<code>load_store_if_needed</code> 在持有 Mutex 锁的情况下执行数据库操作，而另一个线程也在等这把锁。两个线程互相等，就卡死了。</p><p>解决方法是把”检查缓存”和”执行数据库”分开：先检查缓存，释放锁，执行数据库操作，然后再加锁存储。听起来简单，但我改了三个版本才稳定下来。</p><h3 id="坑二：BLOB-解析"><a href="#坑二：BLOB-解析" class="headerlink" title="坑二：BLOB 解析"></a>坑二：BLOB 解析</h3><p>QQ 的消息内容存在 BLOB 字段里，是 Protobuf 编码的二进制数据。早期版本的解析器把 varint 字节解码成了 CJK 扩展区字符，输出一堆”生僻字”垃圾。</p><p>后来我加了个纯净度过滤器：只有 60% 以上的字符是常见汉字才保留。但这个阈值太高了，英文消息会被过滤掉。改成先尝试提取可读文本，再回退到 ASCII 分类，才解决。</p><p>还有一个更离谱的问题：某些二进制 BLOB 会让解析器进入无限循环，CPU 直接拉满 100%。最后加了个操作预算（<code>budget = blob_size × 50</code>），超出预算自动回退，才搞定。</p><h3 id="坑三：UID-映射"><a href="#坑三：UID-映射" class="headerlink" title="坑三：UID 映射"></a>坑三：UID 映射</h3><p>QQ 的发送者不是直接存 QQ 号，而是存一个 UID（像 <code>u_-PBswiplK-7J7bmaQLA-mA</code> 这种）。QQ 号存在另一个映射表里。</p><p>问题是这个映射表的列名全是数字（<code>48901</code>、<code>48902</code>、<code>1002</code>），没有语义。我原来的逻辑是找第一个非数字列作为 UID、第一个数字列作为 QQ 号，但第一列的值是纯数字序号，被误判为 QQ 号了。</p><p>最后写了 4 级查找策略：已知列名直查 → 候选表自动检测 → 模糊表名搜索 → 全表暴力扫描。才把各种 QQ 版本的映射表都兼容了。</p><h3 id="坑四：CSV-乱码"><a href="#坑四：CSV-乱码" class="headerlink" title="坑四：CSV 乱码"></a>坑四：CSV 乱码</h3><p>导出的 CSV 文件用 Excel 打开，中文全是乱码。查了半天发现是编码问题——UTF-8 没有 BOM 头，Excel 默认按 GBK 解析。</p><p>在文件开头加了 3 个字节的 BOM（<code>\xEF\xBB\xBF</code>），问题解决。这种小问题最烦人。</p><h2 id="最终效果"><a href="#最终效果" class="headerlink" title="最终效果"></a>最终效果</h2><table><thead><tr><th>特性</th><th>原版 (Electron + Python)</th><th>Rust 版 (Tauri)</th></tr></thead><tbody><tr><td>安装包大小</td><td>~188MB</td><td>~10MB</td></tr><tr><td>内存占用</td><td>~200MB</td><td>~30MB</td></tr><tr><td>打开 190MB 数据库</td><td>几分钟</td><td>30-60 秒（首次），后续秒开</td></tr></tbody></table><h2 id="功能清单"><a href="#功能清单" class="headerlink" title="功能清单"></a>功能清单</h2><ul><li>密钥提取（Windows Debug API）</li><li>数据库解密（SQLCipher）</li><li>TXT 导出（按时间排序、日期分组）</li><li>CSV 导出（带 BOM，Excel 可直接打开）</li><li>聊天分析（24h 分布、类型饼图、成员排行、高频短语）</li><li>多账号支持（每个 QQ 号独立缓存）</li><li>深色&#x2F;浅色主题</li></ul><h2 id="开源"><a href="#开源" class="headerlink" title="开源"></a>开源</h2><p>项目地址：<a href="https://github.com/yfgug/QQFlow">yfgug&#x2F;QQFlow</a></p><p>目前只支持 Windows，因为密钥提取用的是 Windows API。Mac 和 Linux 的话，密钥提取方式不一样，我还没研究。</p><h2 id="写在最后"><a href="#写在最后" class="headerlink" title="写在最后"></a>写在最后</h2><p>这个项目前前后后写了两个月。说是”重写”，其实大部分时间都在调试。Rust 的编译错误真的让人头大，有时候一个生命周期的问题能卡我半天。</p><p>但最后还是写完了。</p><p>写完之后最大的感受是：很多时候你不需要从零开始造轮子。看看别人怎么做的，把好的思路拿过来，用自己的方式实现一遍，这个过程本身就是学习。</p><p>感谢 WeFlow 作者的开源贡献，没有他的项目，我可能不会想到用这种架构。</p><blockquote><p><em>“代码是整合的艺术，不是原创的专利。”</em></p></blockquote>]]>
    </content>
    <id>https://luanjiahao.qzz.io/2026/05/20/qqflow-rust/</id>
    <link href="https://luanjiahao.qzz.io/2026/05/20/qqflow-rust/"/>
    <published>2026-05-20T10:00:00.000Z</published>
    <summary>
      <![CDATA[<h2 id="起因"><a href="#起因" class="headerlink" title="起因"></a>起因</h2><p>事情是这样的。</p>
<p>我有个习惯，每隔一段时间会把 QQ 聊天记录导出来备份。不是为了什么，就是怕哪天号没了，聊天记录也没了。</p>
<p>之前一直用一个叫 QQFlow 的工具，Electron + Python 写的，能用。但有两个问题：一是软件太大了，安装包 188MB；二是打开大数据库的时候卡得要命，190MB 的数据库要等好几分钟。</p>
<p>有天晚上我又在等它加载，看着进度条一动不动，突然想：要不用 Rust 重写一个？</p>]]>
    </summary>
    <title>用 Rust 重写了 QQFlow，从 188MB 到 10MB</title>
    <updated>2026-08-28T11:20:48.104Z</updated>
  </entry>
  <entry>
    <author>
      <name>YFGUG</name>
    </author>
    <category term="项目" scheme="https://luanjiahao.qzz.io/categories/%E9%A1%B9%E7%9B%AE/"/>
    <category term="Python" scheme="https://luanjiahao.qzz.io/tags/Python/"/>
    <category term="爬虫" scheme="https://luanjiahao.qzz.io/tags/%E7%88%AC%E8%99%AB/"/>
    <category term="数据分析" scheme="https://luanjiahao.qzz.io/tags/%E6%95%B0%E6%8D%AE%E5%88%86%E6%9E%90/"/>
    <category term="B站" scheme="https://luanjiahao.qzz.io/tags/B%E7%AB%99/"/>
    <content>
      <![CDATA[<h2 id="起因"><a href="#起因" class="headerlink" title="起因"></a>起因</h2><p>事情是这样的。</p><p>有天晚上我在 B 站游戏中心闲逛，发现有些游戏明明评分很高，但下载量很低。有些游戏评分一般，下载量却很高。</p><p>我就想，这些数据之间有没有什么规律？</p><p>于是我就写了这个爬虫。</p><h2 id="项目简介"><a href="#项目简介" class="headerlink" title="项目简介"></a>项目简介</h2><p>这个项目干的事情很简单：爬取 B 站游戏中心的数据，然后做分析和可视化。</p><p>爬取的数据包括：游戏名称、类型、平台、评分、关注数、下载量、排行榜数据等等。</p><p>分析的内容包括：类型分布、平台分布、评分分布、关注数与下载量的关系、流水估算等等。</p><h2 id="数据获取模式"><a href="#数据获取模式" class="headerlink" title="数据获取模式"></a>数据获取模式</h2><p>项目支持三种数据获取模式：</p><table><thead><tr><th>模式</th><th>说明</th><th>适用场景</th></tr></thead><tbody><tr><td><code>demo</code></td><td>内置 88 款真实国产游戏的模拟数据</td><td>开发调试、离线演示</td></tr><tr><td><code>web</code></td><td>从 B 站游戏中心 HTML 页面解析</td><td>实际数据采集</td></tr><tr><td><code>api</code></td><td>调用 B 站 API 接口</td><td>实际数据采集</td></tr></tbody></table><p><code>demo</code> 模式是给那些不想爬数据、只想看看分析效果的人准备的。内置了 88 款真实国产游戏的数据，虽然不是最新的，但足够演示用了。</p><p><code>web</code> 模式是通过解析 HTML 页面获取数据，不需要 API key，但速度慢一点。</p><p><code>api</code> 模式是直接调用 B 站的 API，速度快，但可能需要处理反爬。</p><h2 id="流水估算"><a href="#流水估算" class="headerlink" title="流水估算"></a>流水估算</h2><p>这个项目最有意思的部分是流水估算。</p><p>B 站游戏中心不公布游戏的收入数据，所以我只能通过其他指标来估算。我用的指标是关注数和评分。</p><p>关注数代表游戏的热度，评分代表游戏的质量。热度高质量好的游戏，收入大概率不会差。</p><p>估算的公式很简单：流水 &#x3D; 关注数 * 评分系数 * 平台系数。这个公式不精确，但能看出大概的趋势。</p><h2 id="可视化"><a href="#可视化" class="headerlink" title="可视化"></a>可视化</h2><p>项目会生成 7 种图表：</p><ol><li>游戏类型分布饼图</li><li>平台分布柱状图</li><li>评分分布直方图</li><li>关注数与下载量散点图</li><li>流水估算排名图</li><li>热度榜 TOP10</li><li>畅销榜 TOP10</li></ol><p>这些图表用 Matplotlib 生成，样式一般，但数据是准的。如果你想要好看的图表，可以自己换 ECharts 或者 Plotly。</p><h2 id="一些发现"><a href="#一些发现" class="headerlink" title="一些发现"></a>一些发现</h2><p>爬完数据之后，我发现了一些有意思的事：</p><ol><li><p><strong>二次元游戏的评分普遍偏高。</strong> 可能是因为二次元玩家更愿意打高分。</p></li><li><p><strong>独立游戏的关注数和下载量不成正比。</strong> 有些独立游戏关注数很高，但下载量很低。可能是因为玩家只是收藏了，没有真正去玩。</p></li><li><p><strong>B 站游戏中心的热门游戏和 App Store 的热门游戏不太一样。</strong> B 站用户更偏好二次元、独立、小众的游戏。</p></li><li><p><strong>预约榜的游戏，上线后的表现参差不齐。</strong> 预约数高不代表游戏质量好，可能只是营销做得好。</p></li></ol><p>这些发现不算什么深刻的洞察，但对我来说挺有意思的。</p><h2 id="项目结构"><a href="#项目结构" class="headerlink" title="项目结构"></a>项目结构</h2><figure class="highlight nix"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line">bgame<span class="symbol">/</span></span><br><span class="line">├── main.py                 <span class="comment"># 主入口</span></span><br><span class="line">├── bili_game_scraper<span class="symbol">/</span>      <span class="comment"># 爬虫模块</span></span><br><span class="line">│   ├── spider.py           <span class="comment"># 爬虫引擎</span></span><br><span class="line">│   ├── demo_data.py        <span class="comment"># 模拟数据生成器</span></span><br><span class="line">│   └── parsers<span class="symbol">/</span>            <span class="comment"># 解析器</span></span><br><span class="line">├── analysis<span class="symbol">/</span>               <span class="comment"># 分析模块</span></span><br><span class="line">│   ├── statistics.py       <span class="comment"># 统计分析</span></span><br><span class="line">│   └── visualization.py    <span class="comment"># 可视化</span></span><br><span class="line">├── storage<span class="symbol">/</span>                <span class="comment"># 存储模块</span></span><br><span class="line">│   └── sqlite_storage.py   <span class="comment"># SQLite 存储</span></span><br><span class="line">├── scheduler<span class="symbol">/</span>              <span class="comment"># 调度模块</span></span><br><span class="line">│   └── scheduler.py        <span class="comment"># 定时调度</span></span><br><span class="line">├── config<span class="symbol">/</span>                 <span class="comment"># 配置模块</span></span><br><span class="line">│   └── settings.py         <span class="comment"># 配置管理</span></span><br><span class="line">├── data<span class="symbol">/</span>                   <span class="comment"># 数据目录</span></span><br><span class="line">├── charts<span class="symbol">/</span>                 <span class="comment"># 图表目录</span></span><br><span class="line">└── exports<span class="symbol">/</span>                <span class="comment"># 导出目录</span></span><br></pre></td></tr></table></figure><h2 id="快速开始"><a href="#快速开始" class="headerlink" title="快速开始"></a>快速开始</h2><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 安装依赖</span></span><br><span class="line">pip install -r requirements.txt</span><br><span class="line"></span><br><span class="line"><span class="comment"># 一键执行完整流程：爬取→分析→可视化→导出</span></span><br><span class="line">python main.py all</span><br><span class="line"></span><br><span class="line"><span class="comment"># 只爬取数据</span></span><br><span class="line">python main.py crawl --max-pages 5</span><br><span class="line"></span><br><span class="line"><span class="comment"># 只生成分析报告</span></span><br><span class="line">python main.py analyze --save</span><br><span class="line"></span><br><span class="line"><span class="comment"># 只生成可视化图表</span></span><br><span class="line">python main.py visualize</span><br><span class="line"></span><br><span class="line"><span class="comment"># 启动定时爬取</span></span><br><span class="line">python main.py schedule --interval 6</span><br></pre></td></tr></table></figure><h2 id="踩过的坑"><a href="#踩过的坑" class="headerlink" title="踩过的坑"></a>踩过的坑</h2><h3 id="坑一：反爬"><a href="#坑一：反爬" class="headerlink" title="坑一：反爬"></a>坑一：反爬</h3><p>B 站有反爬机制，请求太频繁会被封 IP。我加了随机延时和 User-Agent 轮换，但有时候还是会被封。</p><h3 id="坑二：数据格式"><a href="#坑二：数据格式" class="headerlink" title="坑二：数据格式"></a>坑二：数据格式</h3><p>B 站游戏中心的数据格式不太统一，有些游戏的字段是空的，有些游戏的字段格式不一样。我写了好几个解析器来处理这些特殊情况。</p><h3 id="坑三：分页"><a href="#坑三：分页" class="headerlink" title="坑三：分页"></a>坑三：分页</h3><p>B 站游戏中心的分页逻辑有点奇怪，有时候请求第一页会返回重复的数据。我加了去重逻辑，才解决这个问题。</p><h2 id="开源"><a href="#开源" class="headerlink" title="开源"></a>开源</h2><p>项目地址：<a href="https://github.com/yfgug/bgame">yfgug&#x2F;bgame</a></p><p>如果你对 B 站游戏数据感兴趣，可以玩玩看。代码写得一般，但功能是完整的。</p><h2 id="写在最后"><a href="#写在最后" class="headerlink" title="写在最后"></a>写在最后</h2><p>这个项目是我用来练手 Python 爬虫的。写完之后发现，爬虫这东西，技术上不难，难的是处理各种边界情况。</p><p>反爬、数据格式、分页、编码……每个环节都可能出问题。写爬虫就像是在和网站的开发者斗智斗勇，有时候赢，有时候输。</p><p>但最后爬下来的数据，确实能发现一些有意思的东西。</p><p>这大概就是数据的魅力吧。</p><blockquote><p><em>“数据是新时代的石油，分析是炼油厂。”</em></p></blockquote>]]>
    </content>
    <id>https://luanjiahao.qzz.io/2026/05/18/bilibili-game-scraper/</id>
    <link href="https://luanjiahao.qzz.io/2026/05/18/bilibili-game-scraper/"/>
    <published>2026-05-18T10:00:00.000Z</published>
    <summary>
      <![CDATA[<h2 id="起因"><a href="#起因" class="headerlink" title="起因"></a>起因</h2><p>事情是这样的。</p>
<p>有天晚上我在 B 站游戏中心闲逛，发现有些游戏明明评分很高，但下载量很低。有些游戏评分一般，下载量却很高。</p>
<p>我就想，这些数据之间有没有什么规律？</p>
<p>于是我就写了这个爬虫。</p>
<h2 id="项目简介"><a href="#项目简介" class="headerlink" title="项目简介"></a>项目简介</h2><p>这个项目干的事情很简单：爬取 B 站游戏中心的数据，然后做分析和可视]]>
    </summary>
    <title>爬了 B 站游戏数据，发现了一些有意思的事</title>
    <updated>2026-08-28T11:20:48.100Z</updated>
  </entry>
  <entry>
    <author>
      <name>YFGUG</name>
    </author>
    <category term="装备评测" scheme="https://luanjiahao.qzz.io/categories/%E8%A3%85%E5%A4%87%E8%AF%84%E6%B5%8B/"/>
    <category term="徒步鞋" scheme="https://luanjiahao.qzz.io/tags/%E5%BE%92%E6%AD%A5%E9%9E%8B/"/>
    <category term="装备推荐" scheme="https://luanjiahao.qzz.io/tags/%E8%A3%85%E5%A4%87%E6%8E%A8%E8%8D%90/"/>
    <category term="重装徒步" scheme="https://luanjiahao.qzz.io/tags/%E9%87%8D%E8%A3%85%E5%BE%92%E6%AD%A5/"/>
    <content>
      <![CDATA[<h2 id="先说结论"><a href="#先说结论" class="headerlink" title="先说结论"></a>先说结论</h2><p>买鞋这事，我交了不少学费。</p><p>第一双徒步鞋是迪卡侬最便宜那双，199块。走了一次千岛湖，脚踝差点废了。第二双是某宝上买的”军靴”，说是真皮防水，结果下雨天里面能养鱼。第三双是凯乐石，终于对了。</p><p>所以这篇东西，是我花了两千多块踩坑之后的总结。希望你别走我的老路。</p><h2 id="选鞋就看三点"><a href="#选鞋就看三点" class="headerlink" title="选鞋就看三点"></a>选鞋就看三点</h2><p><strong>第一，高帮。</strong></p><p>重装徒步必须高帮，没得商量。低帮鞋走平路没问题，但背着20公斤的包走碎石坡，脚踝没有支撑，扭伤是分分钟的事。我穿低帮鞋走千岛湖那次，下坡的时候脚崴了一下，疼了两个星期。</p><p><strong>第二，鞋底要硬。</strong></p><p>软底鞋走起来舒服，但走久了脚底板会疼。重装徒步需要鞋底有一定的硬度，才能支撑你的脚。Vibram大底是标配，别省这个钱。</p><p><strong>第三，防水。</strong></p><p>山区天气说变就变，早上晴天下午就可能下雨。不防水的鞋走湿路，脚泡在水里，起水泡是轻的，真菌感染才叫麻烦。Gore-Tex或者其他防水透气面料，必须有。</p><h2 id="我的推荐"><a href="#我的推荐" class="headerlink" title="我的推荐"></a>我的推荐</h2><h3 id="凯乐石-Fuga-EX"><a href="#凯乐石-Fuga-EX" class="headerlink" title="凯乐石 Fuga EX"></a>凯乐石 Fuga EX</h3><p>这双鞋我穿了大半年，走了南太行和武功山，目前是最满意的一双。</p><p>优点是防滑真的好。Vibram Megagrip大底，走湿石头都不打滑。高帮设计，脚踝包裹得很紧，走碎石坡很有安全感。透气性也行，夏天穿不闷脚。</p><p>缺点是鞋底偏硬，刚穿的时候需要磨合。我第一次穿它走了十公里，脚底板有点疼。但穿几次之后就好了，鞋底会根据你的脚型慢慢变软。</p><p>价格400-500块，性价比很高。国产鞋能做到这个水平，我是服气的。</p><h3 id="迪卡侬-MH500"><a href="#迪卡侬-MH500" class="headerlink" title="迪卡侬 MH500"></a>迪卡侬 MH500</h3><p>如果你是入门玩家，走走轻松的线路，这双够了。</p><p>迪卡侬的优点是舒适度高，开箱就能穿，不用磨合。防水性能也不错，我穿着它走过几次雨天，脚一直是干的。</p><p>但重装的话，支撑性差点意思。我穿着它走南太行，走完之后脚踝有点酸。可能是我脚踝本身比较弱，但凯乐石走同样的路就没这个问题。</p><p>价格300-400块，适合轻装徒步和入门重装。</p><h3 id="骆驼-昆仑山系列"><a href="#骆驼-昆仑山系列" class="headerlink" title="骆驼 昆仑山系列"></a>骆驼 昆仑山系列</h3><p>预算紧张的话，这双可以考虑。200多块就能买到防水高帮鞋，性价比确实高。</p><p>但品控不太稳定。我朋友买了一双，穿了三个月鞋底就开胶了。我另一双骆驼倒是穿了大半年还好好的，看运气。</p><p>防滑性一般，走湿石头要小心。有一次我穿着它走溪谷，差点滑进水里。</p><p>适合偶尔走走、预算有限的朋友。</p><h2 id="袜子也很重要"><a href="#袜子也很重要" class="headerlink" title="袜子也很重要"></a>袜子也很重要</h2><p>好的徒步鞋要配好的袜子，别穿棉袜。棉袜湿了之后不干，脚泡在里面会起水泡。</p><p>推荐羊毛袜，透气排汗，走一天脚都是干的。Smartwool有点贵，但确实好用。迪卡侬的SH500也行，便宜一半，质量也过得去。</p><h2 id="买鞋的坑"><a href="#买鞋的坑" class="headerlink" title="买鞋的坑"></a>买鞋的坑</h2><p>最后说几个我踩过的坑：</p><ol><li><p><strong>别网购</strong>。徒步鞋必须试穿，每个人的脚型不一样。网上买回来不合适，退换很麻烦。</p></li><li><p><strong>别买太便宜的</strong>。100块以下的徒步鞋，鞋底软得跟拖鞋一样，走不了远路。</p></li><li><p><strong>别买太好看的</strong>。徒步鞋是用来走路的，不是用来拍照的。好看但不实用的鞋，最后都是吃灰。</p></li><li><p><strong>提前磨合</strong>。新鞋买回来先穿着走几次短途，别直接上长线。我第一次穿新鞋走武功山，差点没走回来。</p></li></ol><h2 id="写在最后"><a href="#写在最后" class="headerlink" title="写在最后"></a>写在最后</h2><p>鞋子是徒步的根基。选对了鞋，路才走得远。</p><p>但说到底，鞋子只是工具。真正重要的，是你愿不愿意迈出那一步。</p><blockquote><p><em>“路在脚下，鞋在脚上，远方在心里。”</em></p></blockquote>]]>
    </content>
    <id>https://luanjiahao.qzz.io/2026/05/15/hiking-gear-recommendations/</id>
    <link href="https://luanjiahao.qzz.io/2026/05/15/hiking-gear-recommendations/"/>
    <published>2026-05-15T04:00:00.000Z</published>
    <summary>
      <![CDATA[<h2 id="先说结论"><a href="#先说结论" class="headerlink" title="先说结论"></a>先说结论</h2><p>买鞋这事，我交了不少学费。</p>
<p>第一双徒步鞋是迪卡侬最便宜那双，199块。走了一次千岛湖，脚踝差点废了。第二双是某宝上买的”军靴”，说是真皮防水，结果下雨天里面能养鱼。第三双是凯乐石，终于对了。</p>
<p>所以这篇东西，是我花了两千多块踩坑之后的总结。希望你别走我的老路。</p>
<h2 id="选鞋就看三点"><a href="#选鞋就看三点" class="headerlink" title="选鞋就看三点"></a>选鞋]]>
    </summary>
    <title>500块能买到什么徒步鞋？踩过的坑都写在这了</title>
    <updated>2026-08-28T11:20:48.104Z</updated>
  </entry>
  <entry>
    <author>
      <name>YFGUG</name>
    </author>
    <category term="徒步攻略" scheme="https://luanjiahao.qzz.io/categories/%E5%BE%92%E6%AD%A5%E6%94%BB%E7%95%A5/"/>
    <category term="华东K2" scheme="https://luanjiahao.qzz.io/tags/%E5%8D%8E%E4%B8%9CK2/"/>
    <category term="九华山" scheme="https://luanjiahao.qzz.io/tags/%E4%B9%9D%E5%8D%8E%E5%B1%B1/"/>
    <category term="难度对比" scheme="https://luanjiahao.qzz.io/tags/%E9%9A%BE%E5%BA%A6%E5%AF%B9%E6%AF%94/"/>
    <category term="徒步路线" scheme="https://luanjiahao.qzz.io/tags/%E5%BE%92%E6%AD%A5%E8%B7%AF%E7%BA%BF/"/>
    <content>
      <![CDATA[<h2 id="先说结论"><a href="#先说结论" class="headerlink" title="先说结论"></a>先说结论</h2><p>华东K2难得多，不在一个量级。</p><p>九华山南北穿越是中高级线路，走完之后你会觉得累，但还能接受。华东K2是顶级虐线，走完之后你会怀疑人生，怀疑自己为什么要来受这个罪。</p><p>但就是有人喜欢这种受罪的感觉。比如我。</p><h2 id="详细对比"><a href="#详细对比" class="headerlink" title="详细对比"></a>详细对比</h2><table><thead><tr><th>维度</th><th>华东K2</th><th>九华山南北穿越</th></tr></thead><tbody><tr><td><strong>距离</strong></td><td>35-42km</td><td>25-30km</td></tr><tr><td><strong>累计爬升</strong></td><td>2500-3000m+</td><td>1500-2000m</td></tr><tr><td><strong>耗时</strong></td><td>2天（快腿1.5天）</td><td>2天</td></tr><tr><td><strong>路况</strong></td><td>60%野路+钻林子+攀爬</td><td>70%成熟古道</td></tr><tr><td><strong>水源</strong></td><td>极缺，全程仅2-3处</td><td>寺庙多，水源充足</td></tr><tr><td><strong>下撤</strong></td><td>几乎没有退路</td><td>多处可下撤</td></tr><tr><td><strong>信号</strong></td><td>大部分无</td><td>大部分有</td></tr></tbody></table><h2 id="华东K2为什么难"><a href="#华东K2为什么难" class="headerlink" title="华东K2为什么难"></a>华东K2为什么难</h2><h3 id="水源是生死线"><a href="#水源是生死线" class="headerlink" title="水源是生死线"></a>水源是生死线</h3><p>K2最变态的地方是水源。</p><p>整条线只有过风坳附近有可靠水源，其他地方要么没有，要么是那种要靠运气才能找到的山涧。每人至少背3-4升水出发，夏天可能要背更多。</p><p>我走K2的时候，背了4升水，走到第二天下午还是喝完了。最后两公里是靠意志力撑到终点的，嘴唇都干裂了。</p><h3 id="钻林子钻到怀疑人生"><a href="#钻林子钻到怀疑人生" class="headerlink" title="钻林子钻到怀疑人生"></a>钻林子钻到怀疑人生</h3><p>独竖尖到过风坳那段，是全程最崩溃的路段。</p><p>不是走，是钻。密林加箭竹，背包外挂全给你刮掉。我挂在背包上的防雨罩、登山杖、水壶，走了一公里全没了。回头去找，根本找不到，被树枝刮到不知道哪里去了。</p><p>而且那种密林里根本没有路，你只能跟着前面的人走。前面的人走哪，你就走哪。有时候前面的人走错了，你跟着也走错，然后一群人一起骂骂咧咧地往回走。</p><h3 id="没有退路"><a href="#没有退路" class="headerlink" title="没有退路"></a>没有退路</h3><p>K2进了山就只能走完。</p><p>不像武功山可以随时下撤到客栈，K2的下撤点几乎没有。你走了十公里想撤退，对不起，没有路下山，只能继续往前走。</p><p>这种感觉很可怕。你以为自己可以随时放弃，但现实告诉你不行。你只能硬着头皮往前走，走到腿软，走到想哭，但还是得走。</p><h3 id="华东最接近”野穿”的线"><a href="#华东最接近”野穿”的线" class="headerlink" title="华东最接近”野穿”的线"></a>华东最接近”野穿”的线</h3><p>K2不是景区，没有台阶，没有补给，没有救援响应。</p><p>你走的每一步都是野路，踩的每一块石头都是野生的。没有护栏，没有指示牌，没有厕所。你要是出了什么事，叫救援都不一定有人来。</p><p>这就是K2的可怕之处。它不是那种修好的景区栈道，它是真正的荒野。</p><h2 id="九华山难在哪"><a href="#九华山难在哪" class="headerlink" title="九华山难在哪"></a>九华山难在哪</h2><h3 id="爬升集中"><a href="#爬升集中" class="headerlink" title="爬升集中"></a>爬升集中</h3><p>九华山的爬升比较集中，天台峰那段海拔爬升800米+。走起来很累，但至少你知道爬完就到了。</p><p>K2的爬升是分散的，你爬完一个山头以为到了，结果前面还有一个，再前面还有一个。心理上的打击比体力上的消耗更让人崩溃。</p><h3 id="石板路废膝盖"><a href="#石板路废膝盖" class="headerlink" title="石板路废膝盖"></a>石板路废膝盖</h3><p>九华山的古道是青石板路，下雨之后极滑。我走的时候赶上下雨，好几次差点滑倒。</p><p>但至少有路。K2有些路段根本就没有路，你得自己找路。</p><h3 id="相对友好"><a href="#相对友好" class="headerlink" title="相对友好"></a>相对友好</h3><p>九华山有寺庙可以补水，有路标可以参考，多处可以下撤。走不动了可以找个寺庙休息，渴了可以找和尚要水喝。</p><p>K2什么都没有。你自己带水，自己带食物，自己找路，自己承担一切风险。</p><h2 id="难度评分"><a href="#难度评分" class="headerlink" title="难度评分"></a>难度评分</h2><table><thead><tr><th>指标</th><th>华东K2</th><th>九华山</th><th>武功山沈明线</th></tr></thead><tbody><tr><td>体能要求</td><td>9</td><td>6</td><td>5</td></tr><tr><td>技术难度</td><td>8</td><td>4</td><td>3</td></tr><tr><td>心理压力</td><td>9</td><td>4</td><td>3</td></tr><tr><td><strong>综合难度</strong></td><td><strong>8.5</strong></td><td><strong>5</strong></td><td><strong>4</strong></td></tr></tbody></table><h2 id="进阶路线建议"><a href="#进阶路线建议" class="headerlink" title="进阶路线建议"></a>进阶路线建议</h2><p>如果你想挑战华东K2，建议先走完这些：</p><p><strong>入门级</strong></p><ul><li>武功山沈明线</li><li>千岛湖环湖</li><li>黄山穿越</li></ul><p><strong>中级</strong></p><ul><li>九华山南北穿越</li><li>南太行</li><li>大别山主峰</li></ul><p><strong>高级</strong></p><ul><li>华东K2</li><li>鳌太穿越</li><li>狼塔C线</li></ul><h2 id="K2注意事项"><a href="#K2注意事项" class="headerlink" title="K2注意事项"></a>K2注意事项</h2><p>如果你决定要走K2，这些事情必须知道：</p><ol><li><p><strong>必须组队</strong>。至少2-3人，不能独行。出了事没人帮你。</p></li><li><p><strong>带足水源</strong>。至少3-4升&#x2F;人，夏天要更多。</p></li><li><p><strong>下载GPX轨迹</strong>。离线地图必备，没有信号的时候靠它。</p></li><li><p><strong>选对季节</strong>。避开6-9月蛇虫高峰期，春秋季最好。</p></li><li><p><strong>有经验再走</strong>。至少走过2-3条中级线，对自己的体能有清楚的认识。</p></li></ol><h2 id="我的选择"><a href="#我的选择" class="headerlink" title="我的选择"></a>我的选择</h2><p>我走完了南太行和沈明线，下一步计划是九华山南北穿越。</p><p>K2我想走，但不着急。等经验更丰富了，体能更好了，再挑战。毕竟山永远在那里，但人只有一个。</p><p>走K2之前，先确保自己能活着回来。</p><blockquote><p><em>“山有等级，人有阶段，循序渐进才能走得更远。”</em></p></blockquote>]]>
    </content>
    <id>https://luanjiahao.qzz.io/2026/05/11/east-china-k2-vs-jiuhuashan/</id>
    <link href="https://luanjiahao.qzz.io/2026/05/11/east-china-k2-vs-jiuhuashan/"/>
    <published>2026-05-11T14:00:00.000Z</published>
    <summary>
      <![CDATA[<h2 id="先说结论"><a href="#先说结论" class="headerlink" title="先说结论"></a>先说结论</h2><p>华东K2难得多，不在一个量级。</p>
<p>九华山南北穿越是中高级线路，走完之后你会觉得累，但还能接受。华东K2是顶级虐线，走完之后你会怀疑人生，怀疑自己为什么要来受这个罪。</p>
<p>但就是有人喜欢这种受罪的感觉。比如我。</p>
<h2 id="详细对比"><a href="#详细对比" class="headerlink" title="详细对比"></a>详细对比</h2><table>
<thead>
<tr>
<th>维度]]>
    </summary>
    <title>华东K2和九华山，到底哪个更难？</title>
    <updated>2026-08-28T11:20:48.104Z</updated>
  </entry>
  <entry>
    <author>
      <name>YFGUG</name>
    </author>
    <category term="徒步游记" scheme="https://luanjiahao.qzz.io/categories/%E5%BE%92%E6%AD%A5%E6%B8%B8%E8%AE%B0/"/>
    <category term="重装徒步" scheme="https://luanjiahao.qzz.io/tags/%E9%87%8D%E8%A3%85%E5%BE%92%E6%AD%A5/"/>
    <category term="武功山" scheme="https://luanjiahao.qzz.io/tags/%E6%AD%A6%E5%8A%9F%E5%B1%B1/"/>
    <category term="沈明线" scheme="https://luanjiahao.qzz.io/tags/%E6%B2%88%E6%98%8E%E7%BA%BF/"/>
    <category term="江西" scheme="https://luanjiahao.qzz.io/tags/%E6%B1%9F%E8%A5%BF/"/>
    <content>
      <![CDATA[<h2 id="为什么是武功山"><a href="#为什么是武功山" class="headerlink" title="为什么是武功山"></a>为什么是武功山</h2><p>南太行走完之后，我说三个月不背包。结果两个月就破功了。</p><p>起因是刷到一条武功山的视频，高山草甸上云海翻涌，配着那种很煽情的音乐。我看了三遍，然后默默打开了12306。</p><p>沈明线是武功山最经典的穿越路线，从沈子村出发，经过金顶、发云界，到明月山结束。三天两夜，25公里左右。听起来不多对吧？但加上1500米的累计爬升和20公斤的背包，就完全是另一回事了。</p><h2 id="沈子村到金顶"><a href="#沈子村到金顶" class="headerlink" title="沈子村到金顶"></a>沈子村到金顶</h2><p>下午三点才开始进山，这个时间点其实挺蠢的。</p><p>正常人都是早上出发，我们因为高铁晚点加上包车堵车，到沈子村已经下午了。队友说要不明天再走？我说来都来了，走吧。</p><p>“来都来了”这四个字害死人。</p><p>前半段还好，沿着溪谷往上爬，树荫遮着不太晒。但越往上越陡，太阳也开始往下走了。我看了看地图，还有三分之一的路，天已经快黑了。</p><p>最后那段路基本是摸黑爬的。头灯照着脚下的石板路，两边是黑漆漆的树林，偶尔有不知名的鸟叫。说实话有点吓人，但已经没有退路了。</p><p>晚上八点多，终于看到金顶的灯光。那一刻腿都软了，不是累的，是松了一口气。</p><h2 id="金顶的日出"><a href="#金顶的日出" class="headerlink" title="金顶的日出"></a>金顶的日出</h2><p>第二天早上五点被冻醒。</p><p>帐篷外面风很大，我裹着睡袋爬出来，天边已经有点发白了。金顶上已经站了不少人，都举着手机等着。</p><p>日出我没怎么拍。因为那一刻你会发现，手机根本拍不出眼睛看到的东西。云海在脚下翻涌，太阳从云层里一点一点升起来，把整个天空染成橘红色。远处的山峰在云海里若隐若现，像水墨画一样。</p><p>我站在那里，风吹得脸有点疼，但不想走。</p><p>有个大姐在旁边打电话，声音很大：”我跟你说，太美了，你快来看！”挂了电话又打：”妈，我在这边挺好的，风景可美了。”</p><p>我在旁边听着，突然有点想家。</p><h2 id="高山草甸"><a href="#高山草甸" class="headerlink" title="高山草甸"></a>高山草甸</h2><p>从金顶到发云界，是全程最美的一段。</p><p>武功山的草甸，怎么说呢，就是那种你在照片里看到会觉得是P的那种绿色。整座山都是绿的，没有树，就是草，一直铺到天边。风吹过来，草浪一波一波的，像海一样。</p><p>走在草甸上，脚感很软，比水泥路舒服多了。但太阳很毒，没有遮阴的地方，走一会儿就一身汗。我把速干衣脱了系在腰上，光着膀子走，被队友拍了照，后来成了群里流传的表情包。</p><p>中午找了个避风的地方吃午饭，压缩饼干加榨菜，配着保温杯里的热水。说实话不好吃，但在那种环境下，什么都觉得香。</p><h2 id="发云界的星空"><a href="#发云界的星空" class="headerlink" title="发云界的星空"></a>发云界的星空</h2><p>晚上在发云界扎营。</p><p>这里是个垭口，风很大，帐篷搭了三次才搭好。晚上煮了泡面，加了根火腿肠，算是改善伙食了。</p><p>发云界的星空比抱犊村还好看。因为海拔高，空气干净，星星像是挂在头顶上一样。我躺在防潮垫上，看了很久。</p><p>流星看到四五颗，许了什么愿忘了。大概是什么暴富之类的俗气愿望吧。</p><p>那天晚上睡得特别好，可能是累的，也可能是海拔高空气好。反正一觉到天亮，帐篷外面的露水都结成冰了。</p><h2 id="明月山的台阶"><a href="#明月山的台阶" class="headerlink" title="明月山的台阶"></a>明月山的台阶</h2><p>第三天是下山。</p><p>从发云界到明月山，路不难走，但台阶很多。武功山的台阶是那种标准的景区台阶，一级一级修得整整齐齐。对于重装徒步来说，这种台阶其实比野路更伤膝盖。</p><p>走到后面，每下一步台阶膝盖都疼。我开始用”之”字形走法，虽然慢但膝盖好受点。队友在后面笑我，说我像个螃蟹。</p><p>中午十二点到了明月山门口，腿已经抖得站不稳了。景区门口有卖水的，我买了一瓶冰可乐，一口气喝完，打了个嗝，觉得这是这辈子喝过最好喝的可乐。</p><h2 id="费用清单"><a href="#费用清单" class="headerlink" title="费用清单"></a>费用清单</h2><p>写这个是为了给想走的人参考：</p><table><thead><tr><th>项目</th><th>费用</th></tr></thead><tbody><tr><td>高铁往返</td><td>约200元</td></tr><tr><td>包车</td><td>约100元</td></tr><tr><td>食品</td><td>约100元</td></tr><tr><td>气罐</td><td>约25元</td></tr><tr><td><strong>总计</strong></td><td><strong>约425元</strong></td></tr></tbody></table><p>不贵对吧？但你得付出体力和膝盖。</p><h2 id="给后来者的建议"><a href="#给后来者的建议" class="headerlink" title="给后来者的建议"></a>给后来者的建议</h2><ol><li><strong>早点出发</strong>。别学我们下午三点才进山，摸黑爬山不好玩。</li><li><strong>带够水</strong>。金顶有卖水的，但贵。自己带够3-4升。</li><li><strong>防晒</strong>。草甸上没遮阴，不涂防晒会脱皮。</li><li><strong>护膝</strong>。下山那段台阶真的要命。</li><li><strong>带件厚衣服</strong>。金顶晚上冷，五月去都得穿抓绒。</li></ol><h2 id="后记"><a href="#后记" class="headerlink" title="后记"></a>后记</h2><p>武功山回来后，我休息了一个星期。每天上班、下班、回家躺着，看着天花板发呆。</p><p>但脑子里一直在想那些草甸、那些云海、那些星空。</p><p>有人说武功山是华东徒步的天花板，我觉得不是。天花板意味着到顶了，但每次走完一条线，我都会发现还有更远的地方想去。</p><p>可能这就是徒步的魔力吧。你以为你在看风景，其实是在看自己。</p><blockquote><p><em>“山不向我走来，我便向山走去。”</em></p></blockquote>]]>
    </content>
    <id>https://luanjiahao.qzz.io/2026/04/26/wugongshan-shenming-line/</id>
    <link href="https://luanjiahao.qzz.io/2026/04/26/wugongshan-shenming-line/"/>
    <published>2026-04-26T10:00:00.000Z</published>
    <summary>
      <![CDATA[<h2 id="为什么是武功山"><a href="#为什么是武功山" class="headerlink" title="为什么是武功山"></a>为什么是武功山</h2><p>南太行走完之后，我说三个月不背包。结果两个月就破功了。</p>
<p>起因是刷到一条武功山的视频，高山草甸上云海翻涌，配着那种很煽情的音乐。我看了三遍，然后默默打开了12306。</p>
<p>沈明线是武功山最经典的穿越路线，从沈子村出发，经过金顶、发云界，到明月山结束。三天两夜，25公里左右。听起来不多对吧？但加上1500米的累计爬升和20公斤的背包，就完全是另一回事了。</p>
<h2 id="沈子村到金顶"><]]>
    </summary>
    <title>武功山沈明线 - 云海之上的三天</title>
    <updated>2026-08-28T11:20:48.104Z</updated>
  </entry>
  <entry>
    <author>
      <name>YFGUG</name>
    </author>
    <category term="徒步游记" scheme="https://luanjiahao.qzz.io/categories/%E5%BE%92%E6%AD%A5%E6%B8%B8%E8%AE%B0/"/>
    <category term="徒步" scheme="https://luanjiahao.qzz.io/tags/%E5%BE%92%E6%AD%A5/"/>
    <category term="随笔" scheme="https://luanjiahao.qzz.io/tags/%E9%9A%8F%E7%AC%94/"/>
    <category term="雨天" scheme="https://luanjiahao.qzz.io/tags/%E9%9B%A8%E5%A4%A9/"/>
    <content>
      <![CDATA[<h2 id="一个人的周末"><a href="#一个人的周末" class="headerlink" title="一个人的周末"></a>一个人的周末</h2><p>周六早上醒来，看了眼手机，八点半。</p><p>窗外在下雨，不是那种哗哗的大雨，是那种细细密密的小雨，下起来就没完没了的那种。我躺在床上，看着天花板发呆，想着今天干点什么。</p><p>加班？不想。打游戏？没意思。刷短视频？刷了一小时更空虚。</p><p>最后我做了一个可能不太理智的决定：去爬山。</p><h2 id="为什么是雨天"><a href="#为什么是雨天" class="headerlink" title="为什么是雨天"></a>为什么是雨天</h2><p>说实话，雨天爬山不是个好主意。</p><p>路滑，视线差，容易失温，手机可能没信号。任何一个户外博主都会告诉你，雨天不要进山。</p><p>但我就是想出去。</p><p>可能是因为连续加了三个星期的班，可能是因为昨天跟客户吵了一架，也可能就是因为在家待着太闷了。总之，我穿上冲锋衣，背上包，出门了。</p><h2 id="山里的雨"><a href="#山里的雨" class="headerlink" title="山里的雨"></a>山里的雨</h2><p>开车到了山脚下，雨还在下。</p><p>停车场就我一辆车。我看了眼天气预报，下午三点会停。现在十点，还有五个小时。我带了足够的水和食物，冲锋衣防水，背包有防雨罩。应该没问题。</p><p>进山之后，雨声变得不一样了。打在树叶上是沙沙的声音，打在石头上是哒哒的声音，打在冲锋衣的帽子上是咚咚的声音。这些声音混在一起，反而让人觉得安静。</p><p>是那种真正的安静。不是办公室里的安静，那种安静里有空调的嗡嗡声、键盘的敲击声、同事小声打电话的声音。这里的安静，是真的什么都没有，只有雨声和你自己的呼吸声。</p><h2 id="溪流"><a href="#溪流" class="headerlink" title="溪流"></a>溪流</h2><p>雨天的溪流比平时好看。</p><p>水量大了，流得急了，冲击石头的声音也响了。我沿着溪流往上走，看着浑浊的水流从山里涌出来，带着树叶和树枝。</p><p>有个地方溪水漫过了小路，我得踩着石头过去。石头上长满了青苔，滑得要命。我小心翼翼地踩上去，脚还是滑了一下，差点掉进水里。最后靠登山杖撑住了，但裤腿湿了一半。</p><p>站在溪边，看着水流从脚边冲过去，突然觉得有点好笑。我一个程序员，周末不打游戏不睡觉，跑到山里来淋雨，图什么呢？</p><p>但就是觉得开心。说不出来的那种开心。</p><h2 id="雾"><a href="#雾" class="headerlink" title="雾"></a>雾</h2><p>走到半山腰的时候，起雾了。</p><p>雾很大，能见度不到十米。路两边的树都看不清了，只能看见脚下的路和前面几米的山路。整个世界都变成了灰白色的，像是被什么东西吞掉了一样。</p><p>我停下来，站在雾里，听着雨声。</p><p>那一刻突然有种奇怪的感觉，像是世界只剩下我一个人了。不是孤独的那种感觉，是一种很平静的感觉。好像所有的烦恼、所有的压力、所有那些乱七八糟的事情，都被这场雾吞掉了。</p><p>我站在那里，不知道站了多久。可能五分钟，可能十分钟。直到一阵风吹过来，冷得我打了个哆嗦，才继续往前走。</p><h2 id="山顶"><a href="#山顶" class="headerlink" title="山顶"></a>山顶</h2><p>到了山顶，雾更大了。</p><p>本来看天气预报说下午三点会停雨，结果两点了还在下。我找了个避风的地方，坐在石头上吃午饭。压缩饼干、火腿肠、保温杯里的热水。不好吃，但在这种环境下，什么都觉得香。</p><p>山顶没有风景可看，全是雾。但我也不在乎。本来就是为了出来走走，不是为了看什么风景。</p><p>吃完午饭，我决定下山。不是因为累了，是因为突然想起明天还有个需求要上线，得回去看看代码。</p><h2 id="下山"><a href="#下山" class="headerlink" title="下山"></a>下山</h2><p>下山的路上，雨小了。</p><p>走到山脚的时候，雨停了，太阳从云层里钻出来。我回头看了一眼山上，雾正在散开，露出绿色的山体。</p><p>停车场里多了几辆车，大概是下午来爬山的人。我换了身干衣服，开车回家。</p><p>路上打开手机，微信消息刷了两百多条。工作群在讨论明天的需求，朋友圈有人在晒周末的下午茶。我关掉手机，继续开车。</p><h2 id="写在最后"><a href="#写在最后" class="headerlink" title="写在最后"></a>写在最后</h2><p>那天晚上，我睡得特别好。</p><p>不是因为累，是因为心里干净了。那些烦人的事情还在，工作还是要做的，客户还是要应付的。但好像没那么烦了。</p><p>可能这就是徒步的意义吧。不是为了征服什么山，不是为了发朋友圈，就是为了找个地方，让自己的脑子安静一会儿。</p><p>雨天的山，没有人，没有风景，但有安静。</p><p>有时候，安静就够了。</p><blockquote><p><em>“山不一定要晴天才好看，就像人生不一定要完美才值得过。”</em></p></blockquote>]]>
    </content>
    <id>https://luanjiahao.qzz.io/2026/04/12/weekend-mountain-rain/</id>
    <link href="https://luanjiahao.qzz.io/2026/04/12/weekend-mountain-rain/"/>
    <published>2026-04-12T12:00:00.000Z</published>
    <summary>
      <![CDATA[<h2 id="一个人的周末"><a href="#一个人的周末" class="headerlink" title="一个人的周末"></a>一个人的周末</h2><p>周六早上醒来，看了眼手机，八点半。</p>
<p>窗外在下雨，不是那种哗哗的大雨，是那种细细密密的小雨，下起来就没完没了的那种。我躺在床上，看着天花板发呆，想着今天干点什么。</p>
<p>加班？不想。打游戏？没意思。刷短视频？刷了一小时更空虚。</p>
<p>最后我做了一个可能不太理智的决定：去爬山。</p>
<h2 id="为什么是雨天"><a href="#为什么是雨天" class="headerlink" titl]]>
    </summary>
    <title>那场雨，那座山</title>
    <updated>2026-08-28T11:20:48.104Z</updated>
  </entry>
  <entry>
    <author>
      <name>YFGUG</name>
    </author>
    <category term="徒步游记" scheme="https://luanjiahao.qzz.io/categories/%E5%BE%92%E6%AD%A5%E6%B8%B8%E8%AE%B0/"/>
    <category term="重装徒步" scheme="https://luanjiahao.qzz.io/tags/%E9%87%8D%E8%A3%85%E5%BE%92%E6%AD%A5/"/>
    <category term="南太行" scheme="https://luanjiahao.qzz.io/tags/%E5%8D%97%E5%A4%AA%E8%A1%8C/"/>
    <category term="河南" scheme="https://luanjiahao.qzz.io/tags/%E6%B2%B3%E5%8D%97/"/>
    <category term="山西" scheme="https://luanjiahao.qzz.io/tags/%E5%B1%B1%E8%A5%BF/"/>
    <content>
      <![CDATA[<h2 id="出发前的纠结"><a href="#出发前的纠结" class="headerlink" title="出发前的纠结"></a>出发前的纠结</h2><p>说实话，出发前一晚我差点放弃。</p><p>天气预报显示周末有雨，南太行的碎石坡下雨天有多滑我是见识过的。但机票买好了，包车也约好了，群里几个兄弟已经到了新乡。算了，死就死吧。</p><p>凌晨四点从郑州出发，天还没亮。司机老张是本地人，开了三个小时山路，把我们扔在马武寨村口。村口有条狗看了我们一眼，又继续睡了，大概是见惯了我们这种背着大包的人。</p><h2 id="碎石坡教会我做人"><a href="#碎石坡教会我做人" class="headerlink" title="碎石坡教会我做人"></a>碎石坡教会我做人</h2><p>前两公里还算友好，沿着溪谷走，水清得能看见底下的石头。我还跟队友开玩笑说这趟轻松。</p><p>然后碎石坡来了。</p><p>南太行的碎石坡，怎么说呢，就是那种你踩上去一块石头，下面三块跟着动的地形。我背着20公斤的包，每一步都走得小心翼翼。走到一半，前面有个哥们滑了一跤，屁股着地滑下去两米，背包上的防雨罩直接刮烂了。</p><p>我在后面看着，默默把登山杖握紧了。</p><h2 id="七星潭"><a href="#七星潭" class="headerlink" title="七星潭"></a>七星潭</h2><p>翻过碎石坡，膝盖已经开始发抖。但转过一个弯，七星潭突然出现在眼前。</p><p>那一刻真的会愣住。</p><p>碧绿色的水，红色的石头，两边是刀切一样的悬崖。阳光从缝隙里照下来，水面闪着光。我站在那里看了好几分钟，忘了膝盖疼这件事。</p><p>有个队友说像九寨沟，我觉得比九寨沟野。九寨沟太精致了，这里还有点荒，有点危险，但就是这种感觉让人上瘾。</p><h2 id="红石河"><a href="#红石河" class="headerlink" title="红石河"></a>红石河</h2><p>继续往前走，到了红石河。</p><p>整条河床都是红色的石头，水流不急，清清凉凉的。我们几个脱了鞋，坐在石头上泡脚，吃压缩饼干。旁边有个大姐在洗衣服，看了我们一眼，大概觉得这群城里人有病。</p><p>那天下午的阳光很好，晒得人懒洋洋的。我躺在石头上，看着天上的云，突然觉得这才是生活该有的样子。不是在办公室里对着电脑，不是在地铁里被人挤成照片，而是躺在太行山的石头上，听着水声发呆。</p><h2 id="抱犊村的夜晚"><a href="#抱犊村的夜晚" class="headerlink" title="抱犊村的夜晚"></a>抱犊村的夜晚</h2><p>晚上住在抱犊村。</p><p>这村子在悬崖上面，进出就一条路，真的是与世隔绝。村里没几户人家，我们住的农家，老板娘做的面条特别好吃，我吃了三碗。</p><p>晚上没有信号，没有wifi，我们几个坐在院子里看星星。太行山的星空，怎么说呢，就是那种你抬头看会觉得有点不真实的程度。银河清清楚楚地挂在天上，偶尔有流星划过。</p><p>有个队友说他上一次看星星还是小时候在老家。我说我也是。</p><p>那一刻突然觉得，我们拼命工作、拼命赚钱，最后可能就是想找回小时候躺在院子里看星星的那种感觉吧。</p><h2 id="第二天的崩溃"><a href="#第二天的崩溃" class="headerlink" title="第二天的崩溃"></a>第二天的崩溃</h2><p>第二天早上起来，腿已经不是自己的了。</p><p>从抱犊村到韩口，说是下坡为主，但南太行的下坡也是那种要命的下坡。石板路、悬崖栈道、密林钻来钻去。有段路贴着悬崖走，我往下看了一眼，腿直接软了。</p><p>我是有点恐高的。</p><p>但走都走了，总不能飞下去。我贴着山壁，一步一步挪，手心全是汗。队友在前面喊”别看下面”，我心想你不说我还不想看呢。</p><h2 id="八里沟"><a href="#八里沟" class="headerlink" title="八里沟"></a>八里沟</h2><p>快到韩口的时候，经过八里沟。</p><p>北方的九寨沟，有人这么叫它。水确实清，清得能看见水底的鱼。但我觉得八里沟比九寨沟好的地方在于，它没那么多人。我们去的时候，整个景区就我们几个人。</p><p>坐在瀑布下面，水雾打在脸上，凉凉的。我突然想起去年在办公室加班到凌晨三点的日子，觉得此刻就像做梦一样。</p><h2 id="回程"><a href="#回程" class="headerlink" title="回程"></a>回程</h2><p>从韩口出来，包车回新乡，然后高铁回郑州。</p><p>在高铁上，我靠着窗户，看着窗外飞速后退的风景。腿疼得要命，脚底两个水泡，肩膀被背包勒出两道红印。但心里是满足的。</p><p>手机恢复信号后，群里消息刷了一百多条。老板问我周一能不能早点到，同事在讨论下个季度的KPI。我关掉手机，又看了一会儿窗外。</p><h2 id="写在最后"><a href="#写在最后" class="headerlink" title="写在最后"></a>写在最后</h2><p>南太行这条线，说实话不轻松。20公斤的负重，碎石坡、悬崖栈道、恐高。走完之后我发誓三个月内不背包了。</p><p>但两个月后，我又在看武功山的攻略了。</p><p>人就是这么贱。</p><p>有人说徒步是自虐，我觉得不是。徒步是让你暂时离开那个被手机、被工作、被各种deadline绑架的世界，回到最简单的生活状态——走路、吃饭、睡觉、看风景。</p><p>就这么简单。</p><blockquote><p><em>“太行山教会我一件事：路是走出来的，不是想出来的。”</em></p></blockquote>]]>
    </content>
    <id>https://luanjiahao.qzz.io/2026/03/20/nantaihang-hiking/</id>
    <link href="https://luanjiahao.qzz.io/2026/03/20/nantaihang-hiking/"/>
    <published>2026-03-20T10:00:00.000Z</published>
    <summary>
      <![CDATA[<h2 id="出发前的纠结"><a href="#出发前的纠结" class="headerlink" title="出发前的纠结"></a>出发前的纠结</h2><p>说实话，出发前一晚我差点放弃。</p>
<p>天气预报显示周末有雨，南太行的碎石坡下雨天有多滑我是见识过的。但机票买好了，包车也约好了，群里几个兄弟已经到了新乡。算了，死就死吧。</p>
<p>凌晨四点从郑州出发，天还没亮。司机老张是本地人，开了三个小时山路，把我们扔在马武寨村口。村口有条狗看了我们一眼，又继续睡了，大概是见惯了我们这种背着大包的人。</p>
<h2 id="碎石坡教会我做人"><a href="#碎石坡教会]]>
    </summary>
    <title>南太行重装穿越记 - 马武寨到韩口</title>
    <updated>2026-08-28T11:20:48.104Z</updated>
  </entry>
  <entry>
    <author>
      <name>YFGUG</name>
    </author>
    <category term="随笔" scheme="https://luanjiahao.qzz.io/categories/%E9%9A%8F%E7%AC%94/"/>
    <category term="徒步" scheme="https://luanjiahao.qzz.io/tags/%E5%BE%92%E6%AD%A5/"/>
    <category term="思考" scheme="https://luanjiahao.qzz.io/tags/%E6%80%9D%E8%80%83/"/>
    <category term="生活" scheme="https://luanjiahao.qzz.io/tags/%E7%94%9F%E6%B4%BB/"/>
    <content>
      <![CDATA[<h2 id="被问过的问题"><a href="#被问过的问题" class="headerlink" title="被问过的问题"></a>被问过的问题</h2><p>“你为什么要去徒步？”</p><p>这个问题我被问过很多次。同事问，朋友问，家人问。他们看着我周末背着大包出门，周一瘸着腿回来，觉得很不理解。</p><p>“有这时间在家躺着不好吗？”</p><p>“花钱买罪受？”</p><p>“你是不是有什么想不开的？”</p><p>说实话，我也问过自己这个问题。</p><h2 id="最初的原因"><a href="#最初的原因" class="headerlink" title="最初的原因"></a>最初的原因</h2><p>最开始徒步，是因为想减肥。</p><p>那时候我刚工作两年，天天加班，外卖，久坐，体重飙到170斤。体检报告好几个箭头朝上，医生说再这样下去不行。</p><p>我想运动，但跑步太无聊，健身房太贵，打球要凑人。后来看到有人徒步，觉得这个好，不用花钱，不用凑人，一个人就能干。</p><p>于是就去了。</p><h2 id="第一次徒步"><a href="#第一次徒步" class="headerlink" title="第一次徒步"></a>第一次徒步</h2><p>第一次徒步去的是本地的一座小山，十公里不到。</p><p>我以为很简单，穿着运动鞋就去了。结果走了一半，脚就疼了。鞋底太软，踩在石头上硌得慌。走到山顶，累得跟狗一样，坐在地上喘了半小时。</p><p>但下山的时候，我突然觉得，这种累和加班的累不一样。</p><p>加班的累是那种闷在心里的累，身体不累，但心累。徒步的累是那种纯粹的累，腿酸，出汗，喘气，但脑子是空的。</p><p>那一刻我好像明白了为什么有人喜欢徒步。</p><h2 id="后来"><a href="#后来" class="headerlink" title="后来"></a>后来</h2><p>后来我开始走更远的路。</p><p>从十公里到二十公里，从一日游到重装穿越。装备也越来越专业，从运动鞋到登山鞋，从双肩包到重装背包，从矿泉水到保温杯。</p><p>体重倒是没怎么减，可能是因为走完之后吃得更多了。</p><p>但我发现，徒步给我的东西，比减肥重要多了。</p><h2 id="徒步教会我的事"><a href="#徒步教会我的事" class="headerlink" title="徒步教会我的事"></a>徒步教会我的事</h2><h3 id="第一件事：专注"><a href="#第一件事：专注" class="headerlink" title="第一件事：专注"></a>第一件事：专注</h3><p>徒步的时候，你只能想一件事：走路。</p><p>看脚下，踩哪里，下一步怎么走。你的脑子被这些简单的事情填满了，没有空间去想工作上的烦心事，没有空间去想房贷和KPI。</p><p>这种专注的感觉，在日常生活中太稀缺了。我们每天被手机、被消息、被各种事情打断，很难真正专注在一件事上。但徒步的时候，你不得不专注。</p><h3 id="第二件事：耐心"><a href="#第二件事：耐心" class="headerlink" title="第二件事：耐心"></a>第二件事：耐心</h3><p>徒步是一个慢过程。</p><p>你不能一下子到山顶，你得一步一步走。有时候走了两个小时，抬头一看，山顶还在云里面。那种感觉会让人崩溃，但你没有选择，只能继续走。</p><p>这和生活很像。很多目标不是一下子就能达到的，你得慢慢来。但现代人太着急了，什么都想快，想快速成功，想快速致富，想快速减肥。</p><p>徒步教会我，有些事情急不来。</p><h3 id="第三件事：接受"><a href="#第三件事：接受" class="headerlink" title="第三件事：接受"></a>第三件事：接受</h3><p>徒步的时候，你会遇到很多意外。</p><p>天气变化，路线走错，装备出问题，身体不舒服。这些事情你控制不了，你只能接受，然后想办法应对。</p><p>生活也是这样。计划赶不上变化，你能做的就是接受现实，然后往前走。</p><h3 id="第四件事：简单"><a href="#第四件事：简单" class="headerlink" title="第四件事：简单"></a>第四件事：简单</h3><p>徒步的时候，生活变得很简单。</p><p>走路，吃饭，睡觉。没有复杂的人际关系，没有烦人的工作邮件，没有无意义的社交。你只需要关注最基本的需求：水、食物、休息。</p><p>这种简单的感觉，让人上瘾。</p><h2 id="关于意义"><a href="#关于意义" class="headerlink" title="关于意义"></a>关于意义</h2><p>有人问我，徒步有什么意义？</p><p>说实话，我不知道。</p><p>徒步不能让我升职加薪，不能让我买房买车，不能让我变得更帅更有魅力。相反，它让我花钱，让我受伤，让我变黑。</p><p>但我就是喜欢。</p><p>喜欢走在山里的感觉，喜欢站在山顶看风景的感觉，喜欢晚上躺在帐篷里听雨声的感觉。这些感觉不能当饭吃，但能让饭吃得更香。</p><h2 id="关于孤独"><a href="#关于孤独" class="headerlink" title="关于孤独"></a>关于孤独</h2><p>徒步大部分时间是孤独的。</p><p>一个人走在山里，没有聊天对象，只能和自己说话。有时候会想很多事，有时候什么都不想。</p><p>我以前害怕孤独，觉得一个人待着会胡思乱想。但徒步之后发现，孤独没那么可怕。相反，孤独的时候，你才能真正面对自己。</p><p>那些平时被忙碌掩盖的问题，在孤独的时候会浮现出来。你得面对它们，解决它们，或者接受它们。</p><p>这大概是徒步给我最大的礼物。</p><h2 id="写在最后"><a href="#写在最后" class="headerlink" title="写在最后"></a>写在最后</h2><p>为什么要去徒步？</p><p>我现在可以回答这个问题了。</p><p>不是为了减肥，不是为了发朋友圈，不是为了证明什么。就是为了暂时离开那个被手机、被工作、被各种deadline绑架的世界，回到最简单的生活状态。</p><p>走路，吃饭，睡觉，看风景。</p><p>就这么简单。</p><p>有人说徒步是逃避，我觉得不是。逃避是不想面对，徒步是为了更好地面对。你走了一圈回来，那些问题还在，但你看问题的角度变了。</p><p>这就是徒步的意义。</p><p>不是逃避，是充电。</p><blockquote><p><em>“山就在那里，所以我去。”</em></p></blockquote>]]>
    </content>
    <id>https://luanjiahao.qzz.io/2026/03/01/why-i-hike/</id>
    <link href="https://luanjiahao.qzz.io/2026/03/01/why-i-hike/"/>
    <published>2026-03-01T12:00:00.000Z</published>
    <summary>
      <![CDATA[<h2 id="被问过的问题"><a href="#被问过的问题" class="headerlink" title="被问过的问题"></a>被问过的问题</h2><p>“你为什么要去徒步？”</p>
<p>这个问题我被问过很多次。同事问，朋友问，家人问。他们看着我周末背着大包出门，周一瘸着腿回来，觉得很不理解。</p>
<p>“有这时间在家躺着不好吗？”</p>
<p>“花钱买罪受？”</p>
<p>“你是不是有什么想不开的？”</p>
<p>说实话，我也问过自己这个问题。</p>
<h2 id="最初的原因"><a href="#最初的原因" class="headerlink" tit]]>
    </summary>
    <title>为什么要去徒步？</title>
    <updated>2026-08-28T11:20:48.104Z</updated>
  </entry>
  <entry>
    <author>
      <name>YFGUG</name>
    </author>
    <category term="徒步游记" scheme="https://luanjiahao.qzz.io/categories/%E5%BE%92%E6%AD%A5%E6%B8%B8%E8%AE%B0/"/>
    <category term="失败" scheme="https://luanjiahao.qzz.io/tags/%E5%A4%B1%E8%B4%A5/"/>
    <category term="徒步" scheme="https://luanjiahao.qzz.io/tags/%E5%BE%92%E6%AD%A5/"/>
    <category term="教训" scheme="https://luanjiahao.qzz.io/tags/%E6%95%99%E8%AE%AD/"/>
    <content>
      <![CDATA[<h2 id="承认失败"><a href="#承认失败" class="headerlink" title="承认失败"></a>承认失败</h2><p>今天写这篇，是想记录一次失败的徒步。</p><p>不是那种”虽然很累但最后还是成功了”的故事，是真的没走完，半路撤了的那种。以前觉得这种事挺丢人的，不愿意说。但后来想想，失败也是经历的一部分，不丢人。</p><h2 id="出发"><a href="#出发" class="headerlink" title="出发"></a>出发</h2><p>那是去年十二月的事。</p><p>我想走一条本地的穿越线，不长，十五公里左右，一天能走完。看了天气预报，晴天，温度五度左右。我觉得没问题，背上包就出发了。</p><p>那天的状态其实不太好。前一晚失眠，只睡了四个小时。早上起来有点头疼，但我觉得走走路就好了。</p><p>这是我犯的第一个错误：身体不舒服还要硬撑。</p><h2 id="山里的变化"><a href="#山里的变化" class="headerlink" title="山里的变化"></a>山里的变化</h2><p>进山的时候天气还不错，阳光照在身上暖暖的。</p><p>但走了两公里之后，天开始阴了。山区的天气变化快，我是知道的，但没想到这么快。十点的时候开始起风，十一点开始下雨夹雪。</p><p>我穿的是冲锋衣和抓绒裤，防水是防水，但不保暖。风一吹，冷得直哆嗦。我加快了脚步，想赶紧走到山顶的亭子避一避。</p><p>这是我犯的第二个错误：天气变化没有及时调整计划。</p><h2 id="滑倒"><a href="#滑倒" class="headerlink" title="滑倒"></a>滑倒</h2><p>走到一半的时候，路开始变滑了。</p><p>雨夹雪落在石头上，结了一层薄冰。我穿着普通登山鞋，鞋底的防滑纹路已经磨平了。每一步都走得小心翼翼，但还是滑倒了。</p><p>是那种脚一滑、屁股着地的滑法。不严重，就是裤子湿了，屁股有点疼。我爬起来继续走，但心里开始打鼓了。</p><p>又走了几百米，又滑了一次。这次是侧着滑的，膝盖磕在石头上，疼得我倒吸一口冷气。</p><p>我蹲在那里，看着膝盖上的淤青，开始认真考虑要不要撤。</p><h2 id="撤退的决定"><a href="#撤退的决定" class="headerlink" title="撤退的决定"></a>撤退的决定</h2><p>说实话，撤退的决定不好做。</p><p>我已经走了六公里了，再走三公里就是山顶，然后就是下坡。如果现在撤，今天就白来了。而且我跟朋友说过要走这条线，空手回去多没面子。</p><p>但我又想了想：如果继续走，还有六公里的路，天气在变差，我的膝盖已经青了，鞋子不防滑，手机信号时有时无。万一出了什么事，叫救援都不方便。</p><p>最后我决定撤。</p><p>面子不值钱，膝盖值钱。</p><h2 id="下山的路"><a href="#下山的路" class="headerlink" title="下山的路"></a>下山的路</h2><p>下山的路比上山还难走。</p><p>上坡的时候还能控制重心，下坡的时候腿在抖，根本控制不住。我几乎是蹲着一步一步挪下去的，登山杖撑在地上，手都握麻了。</p><p>走到山脚的时候，天已经黑了。我钻进车里，打开暖风，换了身干衣服，然后发现自己的手在抖。</p><p>不是冷的，是后怕。</p><h2 id="教训"><a href="#教训" class="headerlink" title="教训"></a>教训</h2><p>那次之后，我给自己定了几条规矩：</p><ol><li><p><strong>身体不舒服不要硬撑。</strong> 失眠、感冒、头疼，任何不舒服都不进山。</p></li><li><p><strong>天气变化要及时调整。</strong> 起风了就加衣服，下雨了就考虑撤退，别赌运气。</p></li><li><p><strong>鞋子一定要防滑。</strong> 我后来换了Vibram大底的鞋，再也没滑过。</p></li><li><p><strong>手机要有信号。</strong> 走之前查一下路线，哪里有信号哪里没有。如果全程没信号，要么别走，要么带卫星电话。</p></li><li><p><strong>撤退不丢人。</strong> 山一直在那里，下次再来就好了。但膝盖坏了，就真的完了。</p></li></ol><h2 id="后来"><a href="#后来" class="headerlink" title="后来"></a>后来</h2><p>后来我又走过那条线。</p><p>是春天去的，天气很好，花开得漫山遍野。我走完全程，站在山顶，看着远处的风景，想起冬天那次撤退。</p><p>没有遗憾，反而有点庆幸。如果那次没有撤，我可能就不敢再来了。</p><p>失败不可怕，可怕的是因为一次失败就不敢再尝试。</p><blockquote><p><em>“山永远在那里，但人只有一个人。”</em></p></blockquote>]]>
    </content>
    <id>https://luanjiahao.qzz.io/2026/02/15/failed-hiking-trip/</id>
    <link href="https://luanjiahao.qzz.io/2026/02/15/failed-hiking-trip/"/>
    <published>2026-02-15T11:00:00.000Z</published>
    <summary>
      <![CDATA[<h2 id="承认失败"><a href="#承认失败" class="headerlink" title="承认失败"></a>承认失败</h2><p>今天写这篇，是想记录一次失败的徒步。</p>
<p>不是那种”虽然很累但最后还是成功了”的故事，是真的没走完，半路撤了的那种。以前觉得这种事挺丢人的，不愿意说。但后来想想，失败也是经历的一部分，不丢人。</p>
<h2 id="出发"><a href="#出发" class="headerlink" title="出发"></a>出发</h2><p>那是去年十二月的事。</p>
<p>我想走一条本地的穿越线，不长，十五公里左右，一天能走完。]]>
    </summary>
    <title>那次没走完的徒步</title>
    <updated>2026-08-28T11:20:48.104Z</updated>
  </entry>
</feed>
