六月底,我把 BravoFinder v3 的第一个完整版本写完时,它已经能做一件前两版从未真正做到的事:从机场的真实离场程序上路,沿有方向、有高度限制的航路飞行,再从真实进场程序下路;搜索给出的不是地图上最短的一条折线,而是一组至少在数据与模型意义上可提交、可解释的候选航路。
那时我以为,最难的部分已经过去了。
一个半月以后再回头看,初版更像是搭起了一副正确的骨架。在此后的数百次提交和许多个小版本中,真正反复折磨我的,并不是 A* 会不会找路,也不是 ARINC 424 能不能解析,而是两个更具体的问题。
第一个问题是:程序和航路网到底在哪里握手? 一条 SID 或 STAR 会经过许多定位点(fix),其中哪些才是程序正式指定的交接点,哪些只是程序内部路过的点?发布入口本身不在航路网上怎么办?机场没有 STAR 又怎么办?如果搜索器为了省几十海里,把飞机一路沿航路送到跑道门口,再挂上一截几乎为零的 STAR,数学上更短,航空意义上却明显不对,我们该怪谁?
第二个问题是:当程序已经算对了,凭什么相信自己把它加速对了? 性能分析工具显示的 1% 真的是 1% 吗?高速缓存未命中率下降是不是就意味着更快?一种理论上能减少八成节点扩展的方案,做成可部署版本以后为什么反而更慢?一项省下几兆内存的改动,如果会让等价候选的顺序跨版本漂移,它还算优化吗?
这一个半月里,迭代压力也不再只来自我自己对着导航数据找茬。引擎开始被嵌进别人的产品:有人直接链接它的静态库,有人提出上层运控真正需要的过滤规则,也有人送来第四个数据加载器。一个原本由作者、数据和算法组成的闭环被打开了。外部使用不会替我做设计,但它会很诚实地告诉我,哪些问题在真实产品里最先疼。
这篇文章不要求读者看过上一篇。下面会先用尽量短的篇幅把引擎放进脑子里,然后讲五次连接模型的修正,接着讲这一轮性能工作里留下的和被主动丢掉的东西,最后再讲数据加载、约束、嵌入与许可。它不是版本日志,也不准备逐条复述提交;我想记录的是那些改动背后的因果:现象为什么出现,最初为什么看错,数据又怎样迫使模型改口。
一 · 先把引擎放进脑子里
BravoFinder 算的不是两座机场之间的地图直线,而是一份航路计划。
真实航路计划大致分成三段。飞机从机场起飞,先沿标准仪表离场程序,也就是 SID,离开终端区;中间进入由航路点和航路组成的巡航航路网;接近目的地后,再进入标准仪表进场程序 STAR,或者在没有 STAR 时衔接进近阶段。计划字符串里还会出现 DCT,表示两个点之间按直飞连接,而不是沿某条有名字的航路。
这里有一个理解后文的关键:航路网和程序不是同一层图。
航路网是一张真正的有向图。顶点是航路点、VOR、NDB 等导航点,边是一段段可以飞行的航路。A* 在这张图上移动。SID、STAR 和进近程序则主要是一串发布航段,它们描述飞机怎样从跑道走到某个交接点,或者怎样从交接点走回跑道。程序航段不会被简单塞进航路图,变成搜索器可以从任意方向、任意位置乱入的普通边;连接器只把程序可用的交接点变成带初始代价的起点或终点候选。
假设一条 SID 沿公布航迹飞到交接点 X 已经走了 35 海里,那么 X 会带着 35 海里的起始代价进入多源 A*。另一条 SID 到 Y 只要 20 海里,Y 的起始代价就是 20 海里。到达侧完全对称:某个 STAR 入口到跑道还剩 60 海里,这 60 海里就是该终点候选的尾部代价。搜索比较的是:
离场程序距离 + 航路网距离 + 到达程序距离 + 软惩罚这就是所谓的多源、多目标 A*。机场不是一个孤零零的图顶点,而是一组带着不同程序成本和航向的候选端点。Yen 再在它之上继续寻找 K 条不同路径。
这种两层拓扑有一个直接后果:程序里的定位点只有同时存在于航路图、并且具备正确方向的航路边时,才能直接成为握手点。STAR 入口需要有入边,因为飞机要从航路网飞到它;SID 出口需要有出边,因为飞机要从它飞进航路网。一个只属于终端程序、没有任何航路边的定位点,即使名字、坐标都存在,也仍然是「离网」的。
核心搜索和数据模型都在一个 C++20 库里。命令行、REST HTTP 和 MCP 只是三扇不同的门,共用同一套类型化查询接口与渲染逻辑;它们不会各自复制一份航路算法。这个分层后来很重要,因为当别人真正开始把引擎链接进自己的产品时,需求就能落实到合适的库层,而不是先被某个命令行选项或某种 JSON 格式绑死。
有了这张简图,就可以进入这一轮迭代最漫长的一条主线:程序与航路网的握手。
二 · 连接模型五幕
v3 初版已经拒绝了「机场直连最近航路点」这种伪造连接。飞机必须依靠真实程序上路、下路;如果数据只给出雷达引导而没有确定定位点,引擎宁可如实输出 RADAR VECTORS(雷达引导),也不凭空推算一个精确交接点。
这个大方向没有错。被真实数据反复打脸的,是交接契约还不够细。最初的规则大意是:一条程序沿途经过的每个在网定位点都可以交接。它比「只用程序最后一点」灵活,也确实解决了很多路线绕远的问题,但它暗中赋予搜索器一种现实里并不存在的自由——可以把程序中的任意过点当成正式入口或出口。
接下来的五次修正,就是逐步把这份自由收回来。
第一幕 · 航路飞到了机场门口
最先暴露的是到达侧。
从纽约飞悉尼时,引擎曾给出一条看起来相当完整的跨洋航路,最后几段却是:
... B581 NN A579 NOBAR B450 TESAT STAR YSSY问题出在 TESAT。它既是悉尼一条 STAR 的末端定位点,又碰巧落在 B450 航路上,距离机场只有约 0.3 海里。按照「程序内任意在网定位点都能交接」的规则,它当然可以成为搜索终点。A* 于是沿洋区航路一直飞到 TESAT,最后只剩机场门口不到一海里的 STAR 残段。
这条路线不是解析失败,也不是图断了。相反,它在现有模型里异常合理。经 TESAT 的方案比从正常远场入口进入 STAR 还短几十海里:尾部代价几乎为零,搜索器自然会让航路网替掉本该由终端程序覆盖的下降段。优化器没有算错,是我们把错误的自由定了一个极低价格。
初步修复是在读取程序时加一道基于距离间隙的过滤规则。如果某个机场同时存在一个近乎贴场的连接点,以及一组距离正常的程序连接点,就剔除那个贴场点;如果整个机场只有一个很短的合法进近,或者下一候选远到几十上百海里,则不贸然删除。这个办法根据全量机场的尾部距离分布推导阈值,也确实把典型问题挡住了。
但我从一开始就知道,它是止血,不是病根。规则里出现了「一海里以内」和「二十海里以内还有候选」这样的经验数字,说明它识别的是症状。TESAT 之所以不该成为入口,不是因为它离机场 0.3 海里;真正的原因是它根本不是这条 STAR 发布的入口。距离只是让错误格外刺眼。
这条临时过滤规则后来被彻底删除。让它得以删除的,是第三幕的结构修复。不过在那之前,另一个与距离完全无关的问题先出现了。
第二幕 · 搜索器在连接点原地掉头
石家庄到广州的一条候选曾选择某条 SID,在 OC 点离开程序,然后接入 B458。单看字符串,程序、航路、进场一应俱全;把几何画出来,问题立刻出现:飞机沿 SID 到达 OC 的航向大约是西北,而离开 OC 后第一段航路几乎向东南,夹角约 157 度。
这不是一段「很贵但勉强能飞」的转弯。更准确地说,它根本不是一个被飞行程序描述出来的航段。模型把 OC 当作一个无尺寸的连接点,于是允许飞机从程序给出的来向瞬间切到几乎相反的航路去向。优化器只计算沿 SID 飞到 OC 的距离和后续航路距离,连接动作本身完全免费,便选择了这次几何上不连贯的握手。
这个后来记录为议题 #20 的问题,表述本身很重要。若把它说成「急转弯耗油,所以加点成本」,修复就像一种舒适性调参;但实际缺口是连接点模型被滥用。软惩罚不是在给真实转弯定价,而是在告诉搜索器:方向一致的交接比南辕北辙的拼接更可信。
实现上,转弯代价又不能伪装成普通边约束。离开顶点 V 时要罚多少,取决于路径是从哪个方向进入 V 的;它是路径相关成本,不是单条边自身的属性。严格做法要把 A* 状态从「顶点」扩成「顶点加来向航向」,但那会让状态空间和工作区内存随航向分桶数成倍增长,并在服务端并发下迅速变成数 GB 内存。
最终采用的是单状态的贪心近似:工作区为每个顶点额外保存当前最优路径的入向,每次尝试下一条边时计算转角。45 度以下不罚,之后逐渐增加,转角越接近 180 度,惩罚增长得越快;它始终是非负软成本,不会因为彻底禁止某次转向而把路堵死。Yen 在重算候选路径成本时使用完全相同的模型,避免 A* 认为一条路便宜、候选排序又按另一套价格计算。
同一查询改进后,出口从 OC 换到方向一致的 UKMIS,交接角从约 157 度降到几度。更重要的是,批量真实路线的成功率不变,普通航路中间的转角分布几乎不动,而 SID/STAR 交接角中的极端大角度显著减少。
这项修复没有替代第一幕用来防止贴场衔接的距离过滤。临时关掉过滤再跑悉尼案例,TESAT 仍会回来,因为到达 TESAT 的航路与程序末段未必形成大转角。一个管「在哪交」,另一个管「怎么交」,两者互不替代。这也是我后来不再试图用一个漂亮公式包治所有连接问题的原因。
第三幕 · 只在发布的门口交接
真正的结构修复来自一个更朴素的问题:航图上,程序到底把哪里标成入口和出口?
对 STAR,ARINC 424 已经给了明确答案。每条进场过渡通常以 IF,也就是初始定位点(Initial Fix)开始;它是飞机进入这段程序的发布入口。后面的 TF、CF 等定位点是沿程序飞过的航段终点,不应该因为碰巧在航路网上,就自动升级成另一个入口。
对 SID,IF 在错误的一端。SID 从跑道向外飞,某些过渡里的 IF 只是那段过渡从哪里开始,真正的发布出口要从记录中派生:取该过渡最后一条带确定定位点的航段,其终点就是出口。航图里加粗的过渡末点,与这条机械规则正好对应。
于是连接器的规则明确为两句:
- STAR 只从发布的 IF 进入;
- SID 只从发布的末端定位点离开。
门的语义与「是否在网」仍然是两个独立条件。一个定位点是 STAR 的 IF,不代表它一定有航路入边;一个定位点是 SID 的末点,也不代表它一定有航路出边。只有发布门和方向性在网条件同时满足,才直接成为搜索端点。
第一版落地时还保留了机场级回退:先收集全机场的发布门;如果一个都无法接入,再允许该机场回到旧的「程序内在网点」模式。回退以机场为单位,而不是逐条程序进行。后者看似保住了每个具名程序的可选性,却会让那些没有在网入口的程序重新把贴场末端点放回候选集,等于从侧门把病根请回来。
全量数据对这次规则调整给出了非常干净的验证。旧模型下,数百个机场存在不到一海里的到达连接;限制到 IF 后,数量降到个位数,残留几乎全是只有一个定位点、总长度为零的退化程序,已经没有任何程序主体可被绕过。悉尼的 TESAT、印尼机场的 MKS 等病灶点不再是「被阈值过滤」,而是根本进不了候选集。
于是第一幕的距离间隙过滤可以删除了。一个带经验阈值的读取阶段补丁,被一个只依赖发布语义的结构规则取代。这是我最愿意看到的重构结果:不是把阈值调得更准,而是让阈值失去存在理由。
这项限制也没有像人们直觉上担心的那样普遍导致绕远。以丹佛到洛杉矶为例,旧模型可以在进场中段一个离机场十几海里的定位点接入;新模型改从真正发布的远场 IF 进入。看起来 STAR 尾段长了很多,但多源搜索会把航路网距离和程序累计距离一起算总账,最后的新路线反而短了三十多海里。所谓「近点必然更省」,只是把程序前面那段距离藏起来后的错觉。
这一步之后,连接模型终于能回答「在哪交」;但它也把两个原先被宽松中段连接掩盖的问题暴露出来:机场根本没有 STAR,或者机场有 STAR,但所有发布门都离网。
第四幕 · 没有 STAR,怎样诚实地抵达
一位把引擎接入产品的开发者在议题 #24 里提出了非常直接的问题:如果目的地没有发布 STAR,引擎怎么把航路接到进近?
过去的做法是退回普通 DCT:找几个离机场最近、具有入边的航路点,把它们作为到达终点。这个回退至少能给出路线,但完全不知道进近程序的初始进近定位点(IAF)在哪里。以太浩湖机场 KTVL 为例,它没有 STAR,却有真实进近,部分 IAF 还就在 V494 上;旧模型仍可能从另一个近点直接连机场,绕过终端过渡。
最初的提议叫「合成 STAR」:把进近程序包装成一条 STAR 使用。这个名字很快被实际的输出语义推翻了。STAR 和进近程序在飞行计划里不是同一种东西;进近标识不该被塞进飞行计划中的 ATS(空中交通服务)航路字符串的 STAR 位置,--star 也不该突然匹配一条进近程序。若为了实现方便而把它伪装成 STAR,字符串虽然看着整齐,语义却是假的,反向解析也会变得含糊。
最终设计叫 DCT-to-IAF。它只在一种情况下启用:机场没有可用 STAR,但存在进近程序,并且用户没有显式指定一个 STAR。连接器只看进近程序的 IF,也就是 IAF,不允许最后进近定位点(FAF)、复飞点(MAPT)或复飞航段混进候选。搜索仍以航路图上的点为边界;提交的航路字符串写成:
... <最后一个在网点> DCT <机场>route.star 保持为空。选中了哪条进近、哪个 IAF、进近方向和可替换选项,都放进结构化元数据,并在文本详情里标为「终端过渡」,而不冒充 STAR。
IAF 本身在网时很好办,它可以直接作为 A* 的终点。更棘手的是离网 IAF:它没有航路入边,搜索器从图里永远到不了它。这里没有为了一个功能去修改采用 CSR(压缩稀疏行)格式存储的只读航路图,也没有创造一类虚拟图边。连接器为 IAF 找到附近几个方向正确的在网点 F,把 F→IAF 的大圆距离连同 IAF→MAPT→机场 的进近主体一起计入 F 的尾部代价。搜索停在 F,进近信息留在元数据里。
这种「代理终点」是一种明确的近似:F→IAF 不会作为真正的图边逐段接受最低偏航高度(MORA)等约束检查,转角也近似计算在 F;但它的改动范围与问题规模相称,不需要让 A*、Yen、路径还原和缓存格式全部认识一张临时叠加图。若将来实战证明这个近似不够,虚拟边仍然可以作为独立议题,而不是让第一版先承担整套复杂度。
尾部代价还必须在 MAPT,也就是复飞点(missed approach point)截断。否则把复飞航段也算进「从 IAF 到机场」的代价,一条进近越完整反而越吃亏。三个数据家族都能从航路点描述码的固定位置识别 MAPT,于是加载器将这一信息记录为一个紧凑的布尔字段。这里真正有意义的不是多存了一个标记,而是距离终于对应了飞机实际进近到落地前的那段程序,而不是把落地失败后的复飞也提前收费。
这一幕最后形成了一种如实反映连接方式的元数据状态:它不是 STAR,也不是完全没有程序参与的单纯 DCT,更不是雷达引导;它是一段由进近 IAF 帮助选择的终端过渡。上层产品由此能知道引擎做了什么,也知道提交的航路字符串为什么仍然写 DCT。
第五幕 · 发布门全都离网
第四幕故意留下了一种情况:机场明明发布了 STAR,但所有 IF 都处于离网状态。初版连接模型会怎样处理?
如果程序中段碰巧有在网定位点,机场级回退会从那里接入;如果连中段也没有在网点,到达侧可能在没有明确提示的情况下改走进近程序,具名 --star 则直接无法选中。这并非只存在于理论上的边缘情况:全库有几十个机场的 STAR 属于前一种,还有几十个属于后一种;SID 侧同样存在数百个离网出口或无定点出口。
这种「从程序中段接入」的做法必须废掉。一个程序的发布入口离网,不等于可以随便从后半段某个碰巧在网的点开始飞。尤其当这个点已经贴近机场时,第一幕的贴场衔接问题会以另一种形式复活。连接模型既然宣称只在发布门交接,就不能一遇到拓扑困难又退回任意中段。
替代方案是通过 DCT 拼接。对离网 STAR 入口 ENTRY,连接器找附近方向正确的在网代理点 F,搜索代价仍计入 F→ENTRY 的距离以及完整的 STAR 主体;生成航路时则把这个由代理点替代的程序门重新插回提交字符串:
... F DCT ENTRY STAR ARRSID 完全对称:
DEP SID EXIT DCT F ...这里与第四幕有一个刻意差异。无 STAR 的 DCT-to-IAF 不把离网 IAF 塞进航路字符串,因为进近标识与内部 IAF 不属于 STAR 段;但有发布 STAR/SID 时,ENTRY 或 EXIT 正是提交字符串中不可省略的程序门。若搜索按经过 ENTRY 的路线计算代价,输出却写成 F STAR ARR,就等于谎称 F 是发布入口,比旧的中段接入更糟。插入程序门不是美化,而是正确性要求。
到达侧还要决定 STAR 直飞拼接与进近程序谁赢。硬性规定「有 STAR 就必须飞 STAR」会让少数机场为了一条离网入口多绕上百海里;完全按距离比较,又会让两个方案只差几海里时轻易抛弃已经发布的 STAR。最后采用温和的优先规则:只有在确实存在 STAR 拼接候选时,才给进近程序的用于候选排序的搜索成本加一小笔固定代价,使两者接近时倾向 STAR,差距很大时仍允许进近程序胜出。
这笔代价只存在于搜索和候选合并,不写进报告距离。地理上的一海里不能因为政策偏好变成十六海里;若进近程序最终胜出,总距离、到达段距离和巡航段距离都必须还原成真实几何值。搜索成本回答「更偏好哪个」,报告距离回答「实际飞多远」,两者不能混账。
把 STAR 与进近程序放进同一个候选池竞争,也打破了一个隐藏的架构假设:过去一组到达候选全是同类,要么全是 STAR,要么全是进近程序,要么全是 DCT;现在同一个代理点可能同时对应 STAR 和进近程序,K 条结果也可能一条走前者、一条走后者。连接类型必须根据每条最终路线分别判断,不能再由整组候选共用的一个布尔值决定。这个改动不显眼,却是第五幕里最容易造成元数据说谎的地方。
至此,连接模型终于形成一份可以逐项检查的契约:
- STAR 在发布 IF 进入,SID 在发布末端定位点离开;
- 在网判定有方向:到达看入边,离场看出边;
- 无 STAR 时可以用进近程序的 IAF 帮助选择 DCT 终端过渡,但不伪装成 STAR;
- 发布门离网时用代理点搜索,并在输出中显式插入
DCT ENTRY/EXIT,不再从程序中段接入; - 转向连续性作为非负软成本进入搜索;
- 政策偏好只改变排序,不污染报告距离。
回看这五幕,初版的问题并不是「不支持 SID/STAR」,而是把「支持程序」理解得太粗。程序不是一个名字,也不是一串可以随处切入的定位点;它是一份关于从哪里开始、在哪里结束、途中如何飞、数据缺失时该承认什么的契约。真实感往往不在大算法里,而在这些交接缝上。
三 · 凭什么加速
连接模型在问「答案是什么意思」,性能工作则在问「怎样更快得到同一个答案」。后一句听起来比前一句简单,实际更危险,因为性能优化最容易制造一种令人安心的错觉:基准测试的数字变好了,所以工作完成了。
这轮优化让我更警惕的不是某段 C++ 写法,而是测量工具本身。工具不会故意撒谎,但它只能展示自己看得见的那一部分;如果把一种观察方式当成全部事实,人会替它完成剩下的误导。
3.1 三种「更好」都可能是假的
第一次翻案发生在 A* 的工作区。
Yen 为了寻找第 2、3、直到第 K 条路线,会在已有路径的许多偏离点上反复调用 A*:假设这里不再沿用当前路线,后面最短该怎么走?朴素实现每次搜索都重新分配并初始化多组与顶点数等长的数组:距离、地理距离、前驱、访问标记、关闭标记。图有二十多万个顶点,k=10 时这种分支搜索可能发生数百次。
早期我用 gprof 看热点,几乎全部时间都落在 RunMultiSearch,独立可见的初始化函数只占约 1%。结论似乎很明确:数组分配不是问题,不值得动。
后来为了另一个性能问题换用 perf record --call-graph dwarf,同一部分开销呈现出了完全不同的分布。std::fill_n 自己消耗的时间接近四分之一。gprof 把大量内联的分配与初始化吞进了主循环,又不采样 malloc 和系统调用,于是「1%」不是这件事只花 1%,而是工具只给它单独记出了 1%。
修法是 SearchWorkspace。数组只分配一次,每轮搜索递增一个轮次编号;某个位置记录的轮次不等于当前值,就在逻辑上视为尚未使用,不再每次把整张表清零。关闭集合也使用独立的轮次标记,而不是 vector<bool>。工作区是每次查询的局部对象,由 Yen 在各次分支搜索之间复用,不用全局状态,也不用会常驻在线程池里的 thread_local。
结果是 k 越大收益越明显,k=10 单这一项就缩短一半以上。更值得记住的是翻案过程:如果只信第一次性能分析,它会永远留在「理论上也许能省一点」的废案区。
第二次误判发生在启发函数。
A* 原先用 Haversine 公式计算大圆距离,估算到各个目标点还剩多远。在 perf 只看「函数自身耗时」的视图里,Coordinate::DistanceTo 和多目标启发函数占比很低,看上去已经被记忆化压到噪声。切换到「连同下层调用一起统计」的调用图后,散落在数学库未命名符号里的 atan2、sin、cos 成本重新聚合回调用者,启发函数实际占了约三分之一。
替代它的是单位球弦长。把地球上的坐标预先映成三维单位向量,两个点的弦长只需向量差、乘加和平方根,不需要 atan2。弦永远短于同一对点之间的圆弧,因此它仍是实际航路距离的下界,A* 仍然不会因为高估剩余距离而错过最优解;任何非负约束惩罚只会把真实成本推得更高,也不会破坏这个下界。
这项改动让 k=1 的纯搜索耗时几乎减半。它的教训与第一次相反:只看函数自身耗时会把大量数学库调用打散,只有把下层调用一并归入上层,才能看见「谁让这些函数忙起来」。
第三种假象来自高速缓存指标。
SearchWorkspace 原本把五类状态分别放在五组平行数组里,也就是 SoA(按字段分开存放)。直觉认为把它们合成一个 VertexState 数组,也就是 AoS(每个顶点的状态聚在一起),可以让同一顶点的数据落在相邻的高速缓存行,减少未命中。实验确实显示未命中率从约 6.5% 降到 5.1%,看起来方向完全正确。
实际运行时间却给出了相反结果。k=1 反而慢了约 6%,k=10 也轻微回退。SoA 的一条高速缓存行可以塞进更多连续的 double;AoS 中每个状态更宽,访问某个字段时有效数据的密度下降,访问总数和未命中总数并没有随未命中率一起变好。现代 CPU 的乱序执行还会隐藏一部分等待时间,工作区本来也不是当时的主瓶颈。
所以调整数据布局这类高速缓存优化,最终只能看同一台机器上的实际耗时对比。未命中率是诊断线索,不是发版理由。指标变漂亮但用户等得更久,仍然是失败。
3.2 留下来的 CPU 优化
SearchWorkspace 解决的是反复分配和清空。它与此前两项 Yen 优化叠加后,收益随 K 快速放大。
第一项是在各次分支搜索之间记住多目标启发函数的结果。同一轮 Yen 里的目标点集合不变,某个顶点的估计剩余距离也不会变;不缓存就会在数百次 A* 里反复对所有目标点计算。第二项是 Lawler 优化:一条新候选从父路径的某个位置分出,那么这个位置之前的分支,父路径已经全部枚举过;下一轮只需从真正发生偏离的位置之后继续,不必重新生成早已在候选池里的分支。
这两项都宣称「结果不变,只少算」。因此除了真实路线回归,还用朴素参考实现做随机图差分测试:优化版和不带 Lawler 的 Yen 在大量随机单源、多源图上逐条比较候选路径、成本与排序。性能承诺可以靠基准测试,等价性承诺必须靠能在结果发生偏差时真正报错的交叉验证。
弦长启发函数之外,逐边执行的搜索循环还做了几项较小但方向一致的清理。原先 Yen 用 std::function 表达节点和边的屏蔽规则,类型擦除后的间接调用会在每次尝试航路边时发生;后来换成明确的 NodeFilter 和 EdgeFilter,小集合预先排序,循环里只做范围检查与二分查找。避让规则也从哈希集合改成排序数组。开放集合使用的堆也把底层数组搬进SearchWorkspace,在各次分支搜索之间清空元素但保留容量,避免不断重新分配。
还有一项不在 FindRoutes 的主要耗时路径上、但用了同一种方法:反向解析提交的航路字符串时,展开一条具名航路需要运行 Dijkstra。旧实现每遇到一个航路代号就新分配一组大数组;现在在整次 ParseRoute 中复用临时SearchWorkspace和轮次标记。它不是最显眼的优化,却说明可复用SearchWorkspace是一种普遍模式,不必被写成只服务某个基准测试的特例。
另一组改动来自图本身。
早期 GraphBuilder 为每个机场连接最近几个在网点,双向加 DCT 边。后来程序连接改成由带初始代价的端点进入搜索,A* 又统一禁止经过机场顶点;这些边因此永远不会被真正遍历。它们仍留在 CSR 里,甚至让搜索白白尝试从航路点走向一个注定被封锁的机场。
插桩计数之后才发现,死 DCT 边不是一点零头,而是全部有向边的近一半,约十五万条。删除它们不改变任何路线,却让边数组缩小约 45%,.bfdb 也瘦了数 MB。旧缓存仍能读、结果仍正确,因为这些边本就不起作用;改变的是文件内容,不是记录布局,因此无需为了「碰过缓存内容」机械抬高容器格式版本。
删掉死边后,机场回退更依赖 NearestOnNetwork。旧实现每次都要扫描二十多万个顶点;构建图时其实已经有一个一度见方的 DegreeGrid,只是用完就扔了。现在空间索引随图常驻,查询从扫描全图变成从机场周围开始逐圈扩大搜索范围,再对少量候选计算精确距离。索引内部也不再让每个网格各自持有一张哈希表和一个动态数组,而是改成三条排序的连续数组,约 1.5 MB、没有逐网格分配,并用同一套规则处理 ±180 度经线回绕。
这两项放在一起看很有意思:一边删除近半数永远无用的边,减少内存与磁盘;另一边花少量常驻内存保留真正有用的空间索引,换掉每次查询的全表扫描。性能优化不是一味缩小所有结构,也不是一味预先计算,而是把预算从死结构移到活结构。
3.3 内存:先量体量,再谈类型
内存紧凑化没有从「把所有字符串改成不拥有内容的 string_view」开始。全量数据先回答了两个问题:哪些对象数量最大,它们的字符串究竟有没有堆分配。
普通标识符最长不过几个字符,绝大多数都被 std::string 直接存放在对象内部,并没有单独申请堆内存。把接口层的少量字符串全面改成 string_view,减少不了预想中那么多堆分配,却会迫使所有相关领域对象共同维护底层字符缓冲区的生命周期。真正的大头是 27 万顶点上的标识符,以及七十多万条 ProcedureLeg 里的定位点。
于是引入了采用 12 字节内置存储的 FixedIdent。它把短标识符和区域码直接存放在对象内部,仍然是值类型,没有悬垂引用。图顶点的标识符由此省下十多 MB;ProcedureLeg.fix 从较大的 Ident 换成同一类型后,预加载模式再省四十 MB 左右。这个改动碰到了编码和解码代码,却没有改变磁盘字节:文件里仍然是相同的两个字符串池引用。判断是否提升 format_version 的依据是磁盘布局是否改变,而不是修改的文件名里有没有 codec。
名字到顶点的查找索引也没有凭直觉从哈希表改成排序数组。隔离微基准先用真实的 27 万个标识符测量:排序数组加二分查找每次慢几十纳秒,构建则多耗时几毫秒,内存却从二十多 MB 降到约 4 MB。查找只发生在端点解析,不在 A* 反复尝试每条边的循环里;这笔交易才成立。若它在每条边上执行,同样的数字会导向相反结论。
之后的结构审计又发现 ProcedureLeg 在枚举宽度和内存对齐上仍有空洞。把航向与距离收窄成 float,把高度约束字段直接放进结构体,同时保持磁盘中仍按双精度浮点数写入,单条程序航段便从 56 字节压到 36 字节,预加载模式的常驻内存再省十多 MB。这里选择 float 的理由不是「单精度总够用」,而是程序航段的航向和距离经过全量对比验证,且不会进入图边权的等价路径排序。
这正好引出另一项被放弃的单精度改造。
3.4 磁盘与启动:一个容器,不等于直接转储内存
最初的缓存分成图、CIFP 程序和导航详情三个文件,各有局部字符串池,部署时还要靠文件名约定把它们配成一组。统一为一个 .bfdb 后,文件头记录数据周期、来源加载器、程序版本和数据能力;段表定位图、程序和导航详情三个区段;所有区段共用一个全局字符串池。
真实数据里定位点、区域码和航路名高度重复。三个局部池合计约 8.7 MB,全局去重后只剩约 1.5 MB,减少八成以上。统一容器最终约六十多 MB,单文件部署,也避免一份图误配另一个 AIRAC 周期的程序段。
缓存不是把 C++ 结构体在内存中的样子直接写进文件。每个整数按固定宽度和小端字节序写入,浮点数写入 IEEE-754 位模式,读取时再逐字段重建;这样 x86 生成的文件可以被 ARM 读取,也不会把编译器插入的填充字节焊进格式。为省十毫秒左右而采用 mmap 零拷贝,不值得牺牲跨平台确定性。
CIFP 又支持按需加载和预加载两种模式。一次性命令行查询默认只读全局字符串池、目录和本次所需机场,峰值内存低;常驻服务可以在打开时把全部程序反序列化并冻结,之后并发查询无需加锁读取。两种模式不是谁淘汰谁,而是用不同内存预算换不同工作负载。
同机端到端测量里,原始 X-Plane 12 数据每次解析建图需要两秒多,读取 .bfdb 约两百毫秒,启动快一个数量级。缓存文件还为全局字符串池和各个区段分别保存 CRC-32C 校验值。边界检查能发现截断和越界,CRC 则负责发现「位模式被改动后仍能解析成合法浮点值」这类结构上仍可解析的静默损坏。缓存是可重建产物,不值得做复杂的跨版本迁移,但值得在坏掉时明确报错,而不是安静地给出一条被改过几海里的航路。
删除机场死边也顺带让文件变瘦。这再一次说明 CPU、内存和磁盘不是三张互不相干的优化表:同一份无用结构会同时污染搜索、常驻内存与序列化体积,真正的结构改进往往跨越三者。
3.5 两次主动放弃
性能工作里,我最想保留的不是最快的数字,而是两项已经写出代码、最后仍然删除的优化。
第一次是把图顶点坐标从双精度 double 改成单精度 float。收益很清楚:约省 2 MB,计算大圆距离时仍可转回双精度,坐标量化误差也只在米级。全套测试通过,八组真实城市对的总成本均未增加。
但其中两组的 K 最短路候选顺序发生了变化。原因不是路线明显变差,而是美国稠密空域里存在许多近乎等价的平行航路;坐标舍入在百万分之一量级轻微扰动边权,就足以改变 Yen 在等价路线之间的排序。用户看到的仍是好路线,却会在同一查询跨版本时得到不同的候选顺序。
两兆只占常驻内存不到 2%。拿这点收益交换用户可见输出的可复现性,不划算,所以整个改动回退。这里的判断与 ProcedureLeg 使用 float 并不矛盾:后者经过真实数据验证,不会成为图搜索的全局边权,也不会参与等价路线的排序;类型相同,不代表它们参与计算的环节具有相同风险。
第二次是利用地标和三角不等式构造 A* 下界的 ALT 启发函数。
在决定要不要做更紧的启发函数之前,我先写了一个完美启发式探针:从所有目标点在反图上运行多源 Dijkstra,算出每个顶点到目标的精确剩余代价,再把它临时当作启发值。它不可能成为正式实现,因为每次查询先跑一次完整 Dijkstra 本身就很贵;但它能回答一个决定性问题——任何不高估剩余距离的启发函数,理论上最多还能少扩展多少顶点。
结果不是「边际提升」,而是扩展的顶点数理论上还可以减少约 85%。这个数字推翻了凭直觉作出的「弦长下界已经够紧」的判断,于是 ALT 值得做原型。
可部署的 ALT 会预先选择十几个地标点,保存每个地标到全图各顶点、以及各顶点到地标的距离,查询时利用三角不等式得到比现有值更紧的下界。原型经历了多轮修正:排除没有出边的地标候选;在 ALT 与弦长下界中取较大值,因为单独使用 ALT 在有向航路图上经常更松;比较按地理距离、图上距离、无向距离和出度过滤等选点方法;增加地标数量;尝试把转弯代价也带进表。
最终正确性没有问题,最优路线成本未增加,预计算也只需不到一秒。但十六个双向距离表会多占三十多 MB 常驻内存,扩展顶点数只减少约三成;第一次计算某个顶点的启发值时,既要计算弦长,又要从大表的分散位置读取数据,常用的 k=1 和 k=3 实际耗时没有下降,k=1 甚至慢约三成。只有 k=10 略有收益。
于是 ALT 没有合入,对应议题也以「不再计划实施」关闭。完美探针没有「预测错」:它证明的是更紧下界的理论天花板,不是承诺某一种地标近似一定能吃到这份空间。原型失败反而把问题说得更清楚——本图的有向可达性稀疏,ALT 给出的三角下界经常为零或弱于弦长,而读取大表又让每次扩展变贵。上限是研究方向的证据,不是发布理由。
3.6 三条留下的方法
这一轮性能工作最后可以收成三条。
第一,重大结论前换一种性能分析工具。gprof 会把内联成本吞进主循环,perf 只看函数自身耗时时又会把数学库成本打散到调用者之外;两者不是谁永远正确,而是盲区不同。看热点既要看函数自身,也要看它连同下层调用的总耗时。
第二,同机实际耗时对比是独立证据。高速缓存未命中、扩展顶点数、分配次数都在解释「为什么」,但用户等待的是时间。结构改动若指标变好、实际耗时变坏,就不能靠解释把它说成成功。
第三,探针只负责回答边界问题。完美启发值告诉我「还有多少理论空间」,随机差分告诉我「优化有没有改结果」,单精度与双精度的对比告诉我「输出稳定性有没有变化」。这些临时实验不一定留下代码,却能阻止一项漂亮、合理、甚至测试全绿的改动被误当成产品收益。
四 · 数据加载器:真实数据会报复
v3 最初只支持 X-Plane 12 原生数据。后来加入 Navigraph DFD v1、DFD v2 两种基于 SQLite 的格式,再通过外部贡献加入 Fenix A320 的导航数据库。四个加载器最终都产出同一份 NavData,共用后面的图构建、程序连接、约束和缓存。
「统一接口」很容易让人误以为,数据加载器只是换几条 SQL。真实数据最擅长报复这种自信。
DFD 的终端航路点表里有一列叫 region_code,看起来正是 Ident.region 需要的 ICAO 区域码。检查全量数据时,却发现其中有大量长度为四五个字符的值,甚至在发布构建中被定长类型静默截断。查看实际数据记录才发现,这列存的是所属机场标识;真正的两字符 ICAO 区域码在 icao_code。若轻信列名,十几万终端定位点会按错误区域码建立索引,查询展示出错,程序与航路点也匹配不上。
另一处坑是 route_identifier。它不是一条物理航路的全球唯一编号;同名 V105 可以在美国、中国、印度分别存在互不相连的实例。若加载器只按航路代号和序号把连续数据行串起来,就会把一段航路的末点接到另一个大陆同名航路的首点,造出跨洋几千海里的幻影航段。正确断点藏在 ARINC 航路点描述码的第二个字符:E 表示航路在此结束(End of Airway)。根据区域码变化断链不行,真实航路会跨区;使用距离阈值也不行,现实里确有很长的洋区航段;看起来最朴素的名字分组反而最危险。
Fenix 数据又有自己的边界。它能提供完整的航路与程序,但部分数据表不保留航路方向、高度带或最低扇区高度(MSA)等信息。最初系统根据加载器名字查一张硬编码能力表;这让缓存只记「自称来自谁」,而没有记「这份文件实际保存了什么」。现在每个加载器必须显式声明 LoaderCapabilities,构建 .bfdb 时把方向、高度带、MORA、MSA 等能力写进文件头;从缓存打开时直接读取文件自身记录的能力,不再靠名字反推。
能力声明目前主要用于诚实报告,尚未自动改写所有搜索策略。例如一个数据源没有航路方向,不能因此假装它已经验证了单向合规;但该怎样自动降级、是否允许用户要求「缺能力即失败」,还需要更完整的策略层。这是后面会提到的公开缺口之一。
第四个加载器由外部贡献进入项目后,这类数据表问题也改变了维护边界。合入一份加载器,不只是接受一次代码贡献;从那以后,上游要对它的单位、空值、列语义、缓存兼容与回归测试持续负责。贡献当然扩大了能力,但它从来不是零成本的「多一个格式」。真正的社区协作从合入之后才开始。
五 · 约束开始长成运控工具
连接模型管程序与航路网怎样握手,约束层管握手之后每条边能不能走、走它要付多少额外成本。两者不能混为一谈:STAR 入口选错,不该靠封锁某条航路补救;某区域的航路规则,也不该写进程序连接器。
引擎的约束接口只返回三种语义:允许、禁止,或加非负软惩罚。任何一个约束禁止,边就不可用;所有软惩罚相加。高度带、MORA、避让点可以做硬过滤,高低空偏好则更适合采用渐进的软惩罚,而不是一刀切地禁止。软成本必须非负,这样弦长启发函数仍是实际剩余成本的下界;想表达「偏好 A」,应惩罚非 A,而不是给 A 负成本奖励。
一位集成方带来的运控需求,把这套框架推到更具体的一步:按「区域 × 航路代号」封锁或惩罚航路。例如中国的某类 J 航路不应被当作美国的高空喷气航路使用,但别的区域里同名或同前缀的航路可能完全合法。
真正难的不是加一个 AirwayRuleConstraint,而是决定匹配粒度。
航路代号不是物理实例的全球唯一标识。全库约三成名字对应多个互不相连的实例;如果图层只按代表航路名称的 airway_id 进行全局封锁,中国境内十几段目标航路会连带误伤一百多段美国、印度、澳洲的合法同名航路。按物理实例整条封锁也仍然过宽:一条航路可能只有边界处的一段进入目标区域,整条连坐会封掉区域外的大部分。
最终规则落到逐航段判断:一段航路的任一端点位于目标区域,且航路代号匹配,才命中。这样既不需要在图里重建不可靠的「航路实例」,也不会把名字相同的全球航路一起封掉。
前缀匹配又有另一重陷阱。J60 的前缀还会命中 J603、J604、J605;A3 更会扩成几十个 A30 到 A399。若要匹配一类航路,应写 J*;若只想匹配一条航路,则应精确写成 J60。因此命令行和结构化请求都明确区分「精确匹配」与「前缀匹配」,不让「实现方便」决定默认语义。
为了不把字符串处理放进 A* 反复执行的逐边循环,规则在查询开始时预计算成两张位掩码表:每个顶点命中了哪些区域规则,每个航路编号命中了哪些代号规则。尝试一条边时,只需读取两端点和航路的位掩码,再做几次位运算。字符串比较、前缀判断和列表展开都只做一次。
这条需求真正像运控工具的地方,不在于它多了一个过滤器,而在于它迫使引擎回答三个问题:规则作用于全球同名航路、某条物理航路,还是单独一段航段;封锁与惩罚如何区分;没有替代路线时是否仍要给出结果。一个航空约束如果只写成「字符串里包含什么」,往往离真实语义还差整整一层模型。
六 · 闭环被打开以后
很长一段时间里,BravoFinder 的迭代是一个封闭循环:我加载一份数据,挑几组机场,发现路线不对,修改算法,再用更多数据验证。这个循环效率很高,因为设计、实现和判断都在同一个人手里;它也有明显盲区,因为「什么最先疼」仍由我自己的用法决定。
引擎被嵌入别人的产品以后,闭环第一次真正打开。
一位长期合作的集成方选择直接链接核心库,而不是把命令行程序当成黑盒调用。他们提交了第四个数据加载器,也持续把上层需求带回公开议题。无 STAR 机场怎样到达,不是我在抽样路线里偶然看到的异常,而是产品开始处理终端区航路时绕不开的用户问题;区域与航路代号组合过滤,也不是为了展示约束框架多么可扩展,而是运控规则真的需要表达「只在这里限制这类航路」。
这些反馈改变了优先级。作者独自使用时,多数据周期、能力声明、航路字符串的反向解析与往返一致性,或者某些没有 STAR 的机场,都可能被视为边缘场景;当一个上层产品要同时管理数据周期、保存计划、展示终端元数据时,它们会立刻变成接口契约。连接模型第四幕就是这样被推上前台的。
外部贡献也改变了「完成」的定义。Fenix 加载器合入的那一刻,只说明第一版数据表映射能工作。之后还要一起面对频率编码、MORA 的南北半球编码、等待程序区域码、程序跑道、AIRAC 周期字段等真实数据问题。上游不能把后续缺陷都视作「贡献者的代码」而置身事外;一旦合入,它就是引擎承诺支持的一条路径。
反过来,集成方也不必复制整座引擎去修自己的需求。连接、约束和数据加载器的改进回到同一个上游,其他使用者会一起受益。这个循环从「作者发现问题—作者修」变成「不同使用场景暴露问题—共同把模型改到能适用于更多情况」。
我不打算用收藏数、下载量或贡献人数来装饰这件事。对这样一个小项目,社区的意义不是突然变得热闹,而是现实世界多了一双不同的眼睛。它会拿你最自信的抽象去做你没做过的事,然后告诉你哪里还漏风。
也正因为嵌入已经从假设变成事实,许可边界不再只是仓库首页的一行字。
七 · 变成一座可嵌入的引擎
为了让直接链接核心库成为常规用法,而不是某位集成者的私人技巧,项目补齐了 SDK 安装、CMake 导出目标、版本头和许可证文件。核心引擎保持不依赖 JSON 与网络;服务层提供类型化查询接口、注册多周期数据库,并统一生成 JSON 或文本;命令行、REST 和 MCP 都只是它的使用者。
HTTP 也不能把「能响应」当成完成。一次路线计算可能花十几毫秒,如果直接跑在 libuv 的事件循环线程上,同一进程里的其他连接都会被堵住。现在 REST 与基于 HTTP 的 MCP 共用一套传输层,把计算交给线程池;后台任务持有连接的强引用,防止客户端中途断开后任务仍访问已经释放的对象;计算完成后回到事件循环线程,先检查连接是否仍然存活,再写响应。工作线程不接触 libuv 句柄,服务器发送事件(SSE)的流式写入也只发生在事件循环线程。
这些工程细节与 A* 没有直接关系,却决定引擎能否被常驻服务安全地调用。数据库打开后除内部同步缓存外只读,同一实例允许多个线程并发查询;按需程序缓存只在查表和插入时加锁,不在读取磁盘时一直占着锁;预加载模式则在打开时一次性填满并冻结,之后无需加锁读取。线程安全不是 README 里的愿望,而是由 ThreadSanitizer 在同机场和不同机场并发查询测试中验证的契约。
许可也在这一阶段重新划界。应用、命令行、HTTP、MCP 等仍采用 MIT;核心引擎改为 LGPL-3.0-or-later。
这个选择不是为了阻止商业产品链接,也不是要求所有上层应用开源。LGPL 允许应用把引擎作为一个库使用,同时把边界放在库本身:如果有人修改、改进了这座通用航路引擎,那些改进应当有机会回馈公共代码库和社区,而不是只锁在某个产品里。
这是我对这件事最直接的动机:希望引擎本身的改进能成为所有人共享的成果。
一个上层产品可以有自己的界面、图表、燃油计算、天气、商业逻辑和许可选择;这些不是 BravoFinder 试图占据的领地。但航路方向怎样解释、程序门怎样衔接、某种数据表怎样正确解码、K 最短路怎样少做重复工作,这些属于一座通用基础引擎。既然社区使用会帮助它发现问题,我也希望对它本身的修正能够继续流动。
统一缓存、SDK、由服务层统一承载的查询逻辑、网络线程模型与 LGPL 看似来自不同章节,其实都在回答同一个变化:BravoFinder 不再只是作者自己在终端里运行的程序,而是一座可能被别的系统长期依赖的引擎。可嵌入意味着接口要有边界,线程要有契约,数据能力要可查询,许可也要明确引擎改进如何回馈社区。
八 · 还没缝上的边
五幕之后,连接模型比初版严格得多,但还不能假装终局已经到来。
复杂的必经点约束、分段代价与 K 路候选同时出现时,仍有一些不够自然的组合。用户要求「必须经过某些点」看似只是把路线切成几段分别搜索,实际上每段各自产生候选后怎样合并、怎样保证全局前 K 条路线的顺序、软惩罚怎样重算,都可能破坏单调性。它需要一份比「多跑几次 A* 再拼起来」更完整的契约。
LoaderCapabilities 已经能随 .bfdb 告诉上层:这份数据有没有航路方向、高度带、MORA 与 MSA。但搜索尚未自动把能力缺失变成统一的降级策略。缺方向时是警告后继续,还是在用户要求严格合规时直接失败?缺 MSA 是否只影响查询展示,还是应该改变某些到达判断?能力被诚实记录只是第一步,怎样使用这些信息还没完全缝上。
代码结构上也仍有债。NavDatabase 承担打开数据、查询、路由、缓存与多种辅助索引,文件虽然已多次拆分,公开类型与内部实现边界仍可以更窄。继续演进时,需要避免让每个新连接场景都往同一个中心对象塞一组状态。
还有一些更根本的现实限制不会靠重构消失。雷达引导程序没有确定出口,代理终点不是真正的虚拟边,转弯惩罚采用单状态近似,导航数据本身也可能缺列、缺机场或只保留部分能力。这里每一项都可以继续向更高保真推进,但前提仍是先承认当前答案的边界,而不是为了让输出看起来完整,就假装答案具有并不存在的精确性。
我现在反而不怕模型继续被数据打脸。第一幕的距离过滤已经被第三幕撤掉,第四幕未覆盖的情况又在第五幕补上;曾经被性能分析工具否决的工作区复用后来翻案,曾经被完美探针看好的 ALT 最终也能因为实际耗时没有改善而删除。可修正并不说明前面的工作白做,它说明项目已经有办法让新证据推翻旧判断,而不用维护旧判断的面子。
尾声
这一个半月里,BravoFinder 表面上增加了不少功能:更多数据加载器、统一缓存、SDK、HTTP、MCP、更多约束与更多终端过渡。可如果只把它们列成功能清单,会错过真正贯穿这些改动的两条线。
一条线是交接。机场怎样把飞机交给航路网,航路网又怎样把飞机交回程序;没有 STAR 时交给谁,发布门离网时 DCT 写在哪里,搜索成本与报告距离怎样各守本分。这里说的「真实」,并不是支持了 SID 和 STAR 两个字段,而是对每一次握手都能回答「为什么在这里」。
另一条线是证据。每种性能分析工具只能提供一种观察角度,高速缓存未命中只能解释一种现象,完美探针只能证明理论上限,测试全绿也不自动等于输出稳定。留下一个优化,需要实际耗时、正确性和产品代价同时说得通;删掉一个已经完成的原型,有时比把它留成开关更诚实。
真实也不只来自数据。一个项目只在作者手里运行时,现实是作者挑选的机场对和工作负载;当它被嵌进别人的产品,现实还包括另一套优先级、另一种失败方式,以及对接口的另一种期望。闭环被打开以后,引擎不再只回答我会问的问题。
这大概也是我现在理解的迭代:不是不断往一个已经正确的核心外面堆功能,而是让新的使用、新的数据和新的测量,持续有资格修改我们对「正确」的定义。下一次真实数据当然还会找到没有缝好的边。仓库的门仍然开着,模型也应该如此。