Skip to content
MeoKit Blog
Go back

一个比 iSH 更快的 iOS Linux x86 容器

Edit page

English Version (英文版)

Podish 图标

一个比 iSH 更快的 iOS Linux x86 容器

TL;DRPodish 是一个面向 iOS / Apple Silicon 专门优化的高性能 Linux x86 用户态容器。它用 C++ 写了一个 i686 解释器核心,用 C# 写了 Linux 兼容层,在 iPhone 17 (A19) 上跑出 CoreMark ~3400,比 iSH 快一倍。

Web Demo: https://podish.meokit.com

GitHub: https://github.com/meokit/podish


项目速览

Podish 的目标很简单:在禁止 JIT 的 iOS 上,尽可能高效地运行 x86 Linux 程序。它并非完整系统模拟器(如 UTM),而是用户态容器——类似 iSH,但代码完全从头重写,并在多个维度上性能翻倍。

它能做什么

类别代表程序状态
Shell / 基础工具链busybox / bash / vim稳定可用
脚本运行时python3 / LuaJIT已验证,可稳定跑 benchmark
构建工具链gcc / make已验证,make compile 跑通
网络 / 开发工具git / OpenSSH手工跑通过,git clone 可用
重型运行时Node.js / Gemini CLI能启动,偶有 crash(V8 JIT 相关)

运行截图

iPhone 上的终端环境

iPhone 上的 Fastfetch

iPhone 上的 Vim

iPhone 上的 Yazi

浏览器演示(无需安装):

浏览器演示

iPhone 跑 CoreMark

iPhone 上的 CoreMark

跑 Gemini CLI

iPhone 上的 Gemini CLI

macOS 上的 Gemini CLI

性能速览

WorkloadPodish (A19)iSH (A19)倍数
CoreMark 1.034471692~2.0×
python primes.py78.3 s684.4 s~8.7×
luajit -joff primes14.5 s27.5 s~1.9×
sh -lc true warm start20 ms30 ms~1.5×

完整基准测试与测试环境说明见文末「性能数据与优化历程」。


以下章节进入技术实现细节。 如果你只想了解项目现状,上面的速览已经够用了;如果你想看解释器是怎么做到这么快的,请继续往下读。


动机与背景

iOS 上有一个著名限制:不允许 JIT。具体来说,系统禁止 WX(Write-XOR-Execute)内存页映射,未经签名的代码无法执行。这意味着你无法在 App Store 下载带 JIT 加速的 UTM,只能使用极其缓慢的 UTM SE;也无法运行 LuaJIT 的 JIT 模式。

我想知道的是:解释器最快能有多快? 能否在不做 JIT 的前提下,靠极致的解释器设计和硬件感知优化,逼近 JIT 的性能?

解释器的主要灵感来自于 LuaJIT Remake。我从它接触到了 Clang 的 preserve_nonepreserve_all[[musttail]] 等 ABI 特性——这些工具或许能让编译器生成堪比手写汇编的解释器热路径。

项目从 hello world 开始(第一周),到跑通 CoreMark(约一个月),再到如今能稳定运行 Busybox、Bash、Python、LuaJIT、GCC,甚至拉起 Node.js 和 Gemini CLI。


整体架构

Podish 并非单层设计,而是采用了明确的分层架构:

flowchart TB
    subgraph Guest["Guest x86 Linux Binary"]
        A["i686 ELF / libc / app"]
    end

    subgraph Core["Emulation Core (C++)"]
        B["Predecode / Block Builder"]
        C["Interpreter Dispatch + Semantics"]
        D["MMU / MicroTLB / SoftTLB"]
    end

    subgraph Runtime["Linux Runtime (C#)"]
        E["Syscall Layer"]
        F["VFS / Process / Signal / Network"]
        G["Netstack (smoltcp)"]
    end

    subgraph Orchestration["Container Layer (C#)"]
        H["Podish.Core / OCI"]
        I["Podish.Cli"]
    end

    subgraph Host["Host Platform"]
        J["iOS / macOS / Browser"]
        K["SwiftUI / SDL"]
    end

    A --> B
    B --> C
    C --> D
    D --> E
    E --> F
    F --> G
    F --> H
    H --> I
    I --> J
    J --> K
%%endmermaid

仓库里大致有几层:

为什么解释器核心用 C++,而系统调用层用 C#?

这是一个刻意的工程权衡。

选 C# 的原因:在项目早期,我需要快速实现约 200 个 Linux syscall 语义、VFS 层、网络栈和容器生命周期管理。C# 的跨平台 IO、字符串处理、异步模型和丰富标准库让我能在一个月内把 CoreMark 从 “hello world” 推进到可运行状态。如果全程用 C++,我可能还在写 std::filesystem 的封装。

边界开销的控制:为了避免 P/Invoke 成为瓶颈,我做了几个关键设计:

实际 profile 显示,在计算密集型负载(如 CoreMark)中,跨语言边界开销占比不到 1%;在 IO 密集型负载(如 git clone)中,瓶颈在网络和 VFS,也不在 P/Invoke。


解释器核心:四大设计决策

解释器的核心目标是:在不使用 JIT 的前提下,将 x86 模拟的开销降至最低。 下面是四个最关键的设计决策。

1. 预解码 + Tail-call Dispatch

传统解释器通常有一个中央分发循环(while (1) { decode; dispatch; execute; }),每次执行完一条指令都要回到这个循环。我的做法是将分发尽量前移:

flowchart LR
    A["x86 Binary"]
    B["BasicBlock"]
    C["Handler: mov"]
    D["Handler: add"]
    E["Handler: jmp"]
    Ret["Return to X86_Run"]
    A -->|"Predecode"| B
    B -->|"Entry"| C
    C -->|"musttail call"| D
    D -->|"musttail call"| E
    E -->|"Block end"| Ret
%%endmermaid

具体做法:

  1. 预解码:先将变长 x86 指令解码成固定长度的中间表示(DecodedOp,32 字节),按基本块顺序存放。
  2. Tail-call 链:每条 IR 自带 handler 函数指针。执行完当前指令后,不回到统一 dispatcher,而是通过 [[musttail]] 直接尾调用到下一条指令的 handler。
  3. 缓存:解码结果被缓存在 BasicBlock 里,避免重复 decode。

这样做的效果:比 QEMU TCI 快约 10 倍。

感谢 Justine Tunney 的 blink 项目,我靠它了解到了 Intel 的 XED,并最终得到了表驱动的 i686 解释器。

DecodedOp 固定 32 字节,对齐到 16 字节边界:

FieldTypeOffsetSize说明
handlerHandlerFunc* (函数指针)08执行该指令语义的入口
next_eipuint32_t84下一条指令的 PC
lenuint8_t121指令字节长度
modrmuint8_t131ModRM 字节
prefixesPrefixes(含位域的 union)141前缀信息(lock/rep/segment…)
metaMeta(含位域的 union)151元信息(has_mem / has_imm / ext_kind…)
ext.dataDecodedMemData1616内存操作数描述(imm / ea_desc / disp)
ext.link.next_blockBasicBlock*248block linking 后缓存的后继块指针
ext.controlDecodedControlFlowData1616控制流目标(target_eip / cached_target)

如果拿一条简单内存 ALU 指令举例:

Guest:
  add eax, [ebx+4]

Predecoded IR:
  entry      = op_add_r32_rm32
  next_pc    = 0x...
  operands   = { dst=eax, src=mem(base=ebx, disp=4, size=4) }
  flags_mode = arithmetic
  control    = fallthrough

2. Parity-only Lazy Flags

x86 的 EFLAGS 是个麻烦的东西。QEMU 用了一套完善的 lazy flags 机制,但我尝试过之后发现:在我的场景里,全 lazy 反而更慢——因为需要频繁把 CC_SRCCC_DSTCC_OP 写入内存,而解释器已经是 memory-bound。

我的折中方案是:只 lazy PF(Parity Flag)

flowchart TB
    A["ALU Instruction"] --> B{"Writes Flags?"}
    B -->|Yes| C["Update CF/ZF/SF/OF/AF/DF in flags_cache[31:0]"]
    C --> D["SetParityState: store result byte to flags_cache[47:40]"]
    B -->|No| E["Skip flags update"]

    F["jcc / cmov / setcc"] --> G{"Cond depends on PF?"}
    G -->|No| H["Read flags_cache[31:0] directly"]
    G -->|Yes| I["EvaluatePF: compute parity from flags_cache[47:40]"]
%%endmermaid

解释器执行期间传递的 flags_cache 是一个 uint64_t

为什么只 lazy PF?因为 CF/ZF/SF/OF 几乎每个 ALU 指令都要写,而且 jcc/cmov/setcc 几乎每条都要读,做 lazy 的存取开销并不划算。但 PF 的求值需要查表,比普通 flags 更昂贵,且使用到 PF 的条件跳转仅有 JP/JNP(cond 10/11),比例很低,值得 lazy。

此外,在基本块内做静态 def-use 分析:如果某条指令写 flags,但后续无读取,就直接换成 no-flags Handler 变体(如 AluAdd<T, false>),编译器会把整个 flags 更新路径完全剪掉。

静态层:在基本块内做 flags 的 def-use 分析。如果某条指令写 flags,但后续无读取,就直接换成 no-flags Handler 变体。这种变体在 AluAdd<T, false>AluSub<T, false> 等模板参数中体现,编译器会把整个 flags 更新路径完全剪掉。

动态层:每条指令执行时,flags_cache 作为寄存器参数在 tail-call 链中传递,永不写回内存。只有 PF 是 lazy 的:

Commit 语义CommitFlagsCache 只在 handler chain 退出边界调用一次(ExitOnCurrentEIPExitOnNextEIP、restart/retry、resolver miss),把寄存器中的 flags_cache 写回 state->ctx.flags_state。成功 chaining 的情况下绝不 commit——因为下一条指令的 handler 还会以寄存器参数的形式继续传递同一个 flags_cacheX86_Run()X86_Step() 在 handler 返回后也不再 commit,避免用 stale 的 caller-side copy 覆盖已更新的状态。

一个有意思的细节是 CheckCondition 的 LUT 路径:绝大多数条件跳转(cond 0-9, 12-15)不依赖 PF,所以它们走 GetFlags32Raw(flags_cache) 直接读取低 32 位,用 LUT 查表决定跳转方向;只有 cond 10/11(JP/JNP)会单独分支到 EvaluatePF()。这样设计让 PF lazy 的额外成本几乎为零。

3. 访存是瓶颈:MicroTLB 与 SoftTLB

项目早期我一直在优化 dispatch 开销,但第一次 profile 发现真正的问题是地址转换

flowchart LR
    nodeA["Guest Address"]
    nodeB{"MicroTLB Hit?"}
    nodeC["host_ptr = guest_addr + addend"]
    nodeD{"SoftTLB Hit?"}
    nodeE["Refill MicroTLB"]
    nodeF["Page Table Walk"]
    nodeG["Permission Check"]

    nodeA --> nodeB
    nodeB -->|Yes| nodeC
    nodeB -->|No| nodeD
    nodeD -->|Yes| nodeE
    nodeD -->|No| nodeF
    nodeF --> nodeG
    nodeG --> nodeE
    nodeE --> nodeC
%%endmermaid

SoftTLB 是一个三路 direct-mapped TLB,三张固定大小的表:

FieldTypeOffsetSize说明
read_tlbstd::array<TlbEntry, 256>04096读权限快速映射表
write_tlbstd::array<TlbEntry, 256>40964096写权限快速映射表
exec_tlbstd::array<TlbEntry, 256>81924096执行权限快速映射表

TlbEntry 对齐到 16 字节,固定 16 字节

FieldTypeOffsetSize说明
taguint32_t04guest page 的 tag(高 20 位)
permProperty (enum)44权限位(Read / Write / Exec / Dirty …)
addendstd::uintptr_t88host_ptr = guest_addr + addend

索引方式也非常直接:

idx = (guest_addr >> PAGE_SHIFT) & 255
tag = guest_addr & ~PAGE_MASK
host_ptr = guest_addr + addend

也就是说,SoftTLB 的职责是将已经查过的 guest page 映射压缩为一个 tag + addend 的快速表项。命中时,只要检查 tag,再做一次加法就能拿到宿主地址;miss 时,才回到慢路径检查权限、补表项、处理异常。

最后发现问题出在一个很低级的 TLB refill 错误上。修复后,CoreMark 先从 600 提到了 800

这让我意识到:地址转换路径会是瓶颈

于是我往这个方向做了进一步设计:

MicroTLB 常驻寄存器,对齐到 16 字节,固定 16 字节

FieldTypeOffsetSize说明
tag_ruint32_t04读权限 tag,默认 0xFFFFFFFF(未命中态)
tag_wuint32_t44写权限 tag,默认 0xFFFFFFFF(未命中态)
addendstd::uintptr_t88host_ptr = guest_addr + addend

注意数据布局,read_tag + write_tag 合并在同一个寄存器,所以这个结构占两个 Host 寄存器。

它的 hit / miss 路径可以用下图表示:

guest address
  -> check read_tag / write_tag
  -> hit: host_ptr = guest_addr + host_guest_addend
  -> miss: full translation + permission check + refill MicroTLB

refill 的时候要检查权限,如果没有 read 权限,清除 read_tag,反之亦然。

这个设计可能有点奇怪,如果读写反复不在同一个页面上,MicroTLB 会 Ping-pong,命中率为0。

但大多数时候有效,我测下来命中率高于50%,实际上只要有一定的命中,能降低从内存的 SoftTLB 读的频率,就有性能提升。

4. Block Linking 与 Superopcode

在这个解释器里,BasicBlock 是内存里的定长头部 + 变长指令流对象。它的头部大致长这样:

BasicBlock 对齐到 16 字节,头部固定 48 字节,后跟变长的 DecodedOp 数组:

FieldTypeOffsetSize说明
chain.start_eipuint64_t (位域: 32 bits)04(嵌入 BasicBlockChainPrefix块起始 guest PC
chain.inst_countuint64_t (位域: 8 bits)41块内指令数
chain.validuint64_t (位域: 1 bit)51 bit块是否有效(用于失效标记)
entryHandlerFunc*88块首条指令的语义入口,解释器直接跳转到这里
end_eipuint32_t164块结束地址(最后一条指令的 next_eip)
slot_countuint32_t204总 slot 数(含末尾 sentinel)
sentinel_slot_indexuint32_t244sentinel 在 slots 中的索引
branch_target_eipuint32_t284分支目标地址(条件跳转/直接跳转时有效)
fallthrough_eipuint32_t324fallthrough 地址
terminal_kind_rawuint8_t361终止类型(None / DirectJmpRel / DirectJccRel / Other)
block_padding0uint8_t371填充
block_padding1uint16_t382填充
exec_countuint64_t408块被执行的次数(用于 profile-guided superopcode)
slots[]DecodedOp[]48变长(每个 32 字节)预解码后的指令流

其中有几个字段很关键:

DecodedOp 自己也不是“只有 handler 指针”的极简结构。它除了 handlernext_eip 之外,还会在扩展区里保存内存操作数信息、控制流目标,或者 block linking 之后缓存下来的 next_block 指针。

所以 BasicBlock 真正做的事情,其实是将三件事整合在一起:

  1. 缓存预解码结果,避免重复 decode
  2. 将一串 DecodedOp 紧凑地排列成可 tail-call 的执行流
  3. 给 block linking / superopcode / profile 这些后续优化提供稳定载体

后续在做 block linking 时,直接在现有 BasicBlock 上进行拼接和复用。

flowchart LR
    A["Block A"] -->|"fallthrough"| B["Block B"]
    A -->|"branch taken"| C["Block C"]

    D["Superopcode"] -->|"fused"| E["pop ebx ; pop esi"]
    D -->|"fused"| F["test eax, eax ; je"]
    D -->|"fused"| G["mov eax, [esp] ; sub reg, eax"]
%%endmermaid

Block Linking:如果一个基本块足够短、指令不跨页,而且控制流关系简单,就将后继块直接接到当前块尾部,减少块间跳转时的间接访存。

Superopcode:并非简单统计高频 bigram,而是围绕高频 anchor instruction 选取种子,再看它与前后指令的 def-use 关系,仅融合存在 RAW 依赖的组合。这一策略最终生成了约 256 个 superopcode,也是性能首次稳定跨过 3000+ CoreMark 的关键步骤之一。

代表性 superopcode:

候选发现流程本质上也不复杂:

block trace
  -> 热点统计
  -> anchor 选择
  -> def-use / RAW 过滤
  -> 代码生成
  -> 回归验证与收益复核

SMC(自修改代码)处理

SMC(Self-Modifying Code)是现代 JIT 引擎(V8、LuaJIT、.NET JIT)的标配:它们先写一段机器码到内存,再跳转过去执行。模拟器必须正确处理这种情况。

我的思路并非”监听整个地址空间的写操作”,而是复用 MMU 的权限系统,将检测成本压进权限位:

flowchart TB
    A["mmap/mprotect<br/>PROT_EXEC \| PROT_WRITE"] --> B["Register External Alias"]
    B --> C{"Live BasicBlock<br/>on this page?"}
    C -->|Yes| D["Set ForceWriteSlow<br/>on guest page entries"]
    C -->|No| E["Normal page"]

    F["Guest Write Instruction"] --> G{"ForceWriteSlow?"}
    G -->|Yes| H["invalidate_code_cache_page"]
    H --> I["Mark blocks invalid"]
    G -->|No| J["Fast write path"]
%%endmermaid

当某个 host page 同时被映射为可执行可写时,MMU 会把它标记为 External Alias。如果该页上已缓存了 BasicBlock,MMU 会给对应 guest page 表项打上 ForceWriteSlow。后续任何对该页的写操作,在 TLB refill 时会命中 ForceWriteSlow,走慢路径调用 invalidate_code_cache_page,将该页关联的所有 BasicBlock 标记为失效。

执行期竞争:仅靠”写的时候失效 block cache”还不够。如果当前 EIP 正好落在被写的页上,解释器 tail-call 跳转到的下一条指令可能已经被改写。为此实现了 ShouldInterceptExecWriteForSmc:一旦检测到当前 EIP 所在页被修改,会立即 yield 并切换到单指令安全模式max_insts = 1),确保“写操作”与“跳转到新代码”之间有一个明确的指令边界,避免竞争。

多 Engine TLB 一致性clone(CLONE_VM) 创建的新线程共享同一个 MmuCore,但每个 Engine 有自己的 SoftTLB。为此实现了 RuntimeTlbShootdownRing(1024 槽 ring buffer),页表变更方把被 flush 的 guest page 写入 ring,其他 Engine 在下次进入 X86_Run 时同步消费。

这套机制让 LuaJIT 可以稳定运行,也让 Node.js / V8 能够启动。目前偶发 crash 是已知限制,可能与指令集实现或 syscall 不完整有关。

SMC 机制的细节展开

上面的概述已经说明了思路,但如果你在做类似的设计,下面这段值得一看。核心思想是将所有检测成本压进 MMU 权限位,避免全局的写监听或反汇编扫描

External Alias 追踪MmuCore 内部维护了一个 external_aliases 映射表(key 是 host page 指针,value 包含 exec_countwrite_count 和关联的 guest page 集合)。当 C# 层通过 mmap 把一个 host page 同时映射为 External + Write + Exec 时,这个页就会被注册到这张表里。这张表只在页表变更时更新(mmap/mprotect/munmap),热路径里完全不碰。

ForceWriteSlow 的惰性武装。真正决定一个外部页是否需要走 SMC 检测的,并非取决于它本身是否带有 Exec 权限,而是取决于它上面是否缓存了 BasicBlockrefresh_smc_armed_for_host_page() 会在两种情况下被调用:

  1. 新解码了一个 block,需要检查它所覆盖的 host pages 上是否有可写的外部映射;
  2. 外部映射被建立或修改(比如 mprotect 加了 PROT_WRITE)。

如果 (exec_count > 0) && (该 host page 上存在 live block),就给这个页对应的所有 guest page 表项打上 ForceWriteSlow。这个 bit 是运行时临时标记,不会持久化、不会被 fork 拷贝,也不出现在页表的深拷贝里。

写路径拦截链。当 guest 执行一条写指令时:

write() -> MicroTLB miss -> SoftTLB miss -> resolve_slow()
  -> 查 page directory -> 发现权限含 ForceWriteSlow
  -> 调用 invalidate_code_cache_page(guest_addr)
  -> 将关联 host page 上的所有 BasicBlock 标记为 invalid
  -> 继续完成实际内存写

invalidate_code_cache_page 并非遍历整个 block cache,而是利用 page_to_blocks 反向索引表(host page → block list)做到 O(受影响 block 数) 的局部失效。被标记为 invalid 的 block 会在下次 X86_Run 尝试进入时自动重新解码。

执行期竞争:正在跑的旧代码 vs 刚写的新代码。仅靠“写的时候失效 block cache”还不够——如果当前 EIP 正好落在被写的页上,解释器 tail-call 跳转到的下一条指令可能已经被改写。为此 mmu_impl.h 里有 ShouldInterceptExecWriteForSmc

if (state->intercept_exec_write_for_smc && !state->allow_write_exec_page) {
    uint32_t current_page = state->ctx.eip >> 12;
    uint32_t target_page  = addr >> 12;
    if (target_page == current_page || target_page == current_page + 1)
        return true;  // 触发 SMC yield
}

一旦命中这个条件,写操作不会立即执行,而是把 state->smc_write_to_exec 置位,同时让当前 handler 返回一个特殊的 yield flow。X86_Run 的主循环检测到 smc_write_to_exec 后,会进入单指令安全模式:

这样确保了“写操作”与“跳转到新代码”之间有一个明确的指令边界,不会发生竞争。

多 Engine 共享下的 TLB 一致性。当 clone(CLONE_VM) 创建新线程时,多个 Engine 共享同一个 MmuCore,但每个 Engine 有自己的 SoftTLBblock_lookup_cache。Engine A 的 invalidate_code_cache_page 不会自动刷新 Engine B 的本地 TLB。为此 MmuCore 里有一个 RuntimeTlbShootdownRing(1024 槽的 ring buffer):

这个 ring 的容量足够覆盖绝大多数单次 mprotect/munmap 的页范围;如果超出容量,直接发一个全量 flush。


对 Copy-and-Patch JIT 的尝试

Copy-and-patch 的核心思路:先将 Opcode handler 预编译成 binary stencil,运行时仅做”拷贝模板 + patch 常量/地址/跳转槽位”。理论上可以避开完整后端、寄存器分配和传统机器码生成器的复杂度(相关论文:Copy-and-Patch Compilation)。

由于我已经用 preserve_none + [[musttail]] 将解释器热路径压得很低,我尝试将 handler 变成 stencil 再 patch 参数,连接成机器码。预期 200%+ 的性能提升,实际上比解释器还略慢

原因:direct-threaded interpreter 已经把 dispatch 压得很低了,瓶颈变成了访存、地址转换、状态维护和 I-Cache 压力。stencil 虽然省掉了一些字节码读取,但 patch 后代码膨胀,I-Cache 压力反而更大。

这个失败很有价值:它证明了劣质 JIT 不如好的解释器,也迫使我更深入地 profile 访存路径。


多线程模型

flowchart TB
    subgraph Scheduler["KernelScheduler (1 Host Thread)"]
        S["Scheduler Loop"]
    end

    subgraph TaskA["FiberTask A"]
        A1["Engine A"]
        A2["SoftTLB A"]
        A3["block_lookup_cache A"]
    end

    subgraph TaskB["FiberTask B"]
        B1["Engine B"]
        B2["SoftTLB B"]
        B3["block_lookup_cache B"]
    end

    subgraph Shared["Shared (Atomic RC)"]
        M["MmuCore"]
        R["RuntimeTlbShootdownRing"]
    end

    S -->|"yield / switch"| A1
    S -->|"yield / switch"| B1
    A1 --> M
    B1 --> M
    M --> R
%%endmermaid

我实现了 clone/fork/vfork 和基本的 pthread 语义,但并发模型并非操作系统意义上的多线程并行

上限:guest 的多个线程在宿主层面是串行调度的,不能利用多核并行。对于 shell、Python、编译任务来说通常足够;重度并行科学计算会受到明显限制。


Linux 兼容层与可用性

除了 CPU 核心之外,项目还实现了一层 Linux 兼容环境,包括:

这部分决定了它能不能从 benchmark 走向真实工作负载。

类别代表程序 / 证据当前状态主要限制
shell/bin/sh -lc true已验证目前正文主要覆盖 shell 启动和短命令
coreutils / archivegrep -R / tar cf / tar xf已验证
scriptingpython (primes.py)已验证workload 当前只有少量样本,容易受手机上温度影响
JIT-heavyLuaJIT (primes_jit.lua)已验证结果曾被 notify socket / 网络路径 bug 污染,已修复
build-oriented mixed workloadmake compile on CoreMark tree已验证当前主表按 compile-only 口径统计
network / tooling compatgit clone兼容性已验证网络波动大

图形与音频:实验性支持

代码库里已有两条实验性的多媒体管线,但还未成熟到进入日常测试:

Wayland Foot 终端演示

这两个功能目前只在 macOS 上支持,iOS / Wasm 还没做适配。


性能数据与优化历程

从 600 到 3000

阶段关键变化CoreMark备注
初始版本baseline interpreter~600ABI / TLB 设计已就位,但 TLB bug 让地址转换热路径几乎完全失效
修 TLB bugfix address translation path~800确认地址转换是早期最大问题
热路径优化PC / block budget / template specialization~1500不再每条指令都做多余状态写内存
访存整理data layout / paired loads~2000优化重点从 dispatch 转向访存组织
lazy flagsstatic prune + PF lazy~2200全 lazy flags 试验版更慢,选择中庸方案
block linkingappend simple successor block~2500降低块边界成本
superopcodeprofile-guided fused ops~3000约 256 个 anchor + RAW superopcode 把结果稳定在 ~3000

核心体会:

完整基准测试

测试环境

启动速度与计算密集任务

WorkloadPodish(A19)Podish(M3 Max)iSH (A19)Podman i386 (QEMU JIT)QEMU TCINative host (M3 Max)测试说明
sh -lc true warm start20 ms20 ms30 ms30 ms30 ms10 ms端到端启动成本;Podish 用 Podish.Cli run;QEMU 显式 qemu-i386 -L <rootfs>
CoreMark 1.03447296716921145632538087
python primes.py78.3 s89.4 s684.4 s40.9 s787.3 s1.8 s脚本语言测试用例来自 kostya/benchmarks
luajit primes3.1 s4.0 s46.6 s1.7 s39.3 s0.2 s
luajit -joff14.5 s17.3 s27.5 s7.1 s152.7 s0.7 s

文件 IO 和混合任务

WorkloadPodish(A19)Podish(M3 Max)iSH (A19)Podman i386 (QEMU JIT)Native host (M3 Max, arm64)备注
grep -R on CoreMark tree50 ms50 ms70 ms70 ms10 ms排除 .git, GNU grep
tar cf CoreMark tree40 ms30 ms40 ms97 ms10 msGNU tar
tar xf CoreMark tree250 ms250 ms170 ms161 ms30 msGNU tar
make compile on CoreMark tree9470 ms10460 ms11430 ms7330 ms210 msmake compile;native 为单进程 make -j1 compile CC=clang
git clone CoreMark3230 ms3300 ms5190 ms2660 ms2980 ms只做兼容性验证,不作为性能结论;网络波动大

以上两张表,除有明确说明的以外,均为 5 次实验的中位数。QEMU TCI 模式在文件 IO 测试中暂缺(太慢,意义不大)。

两个”反常识”的数据现象

A19 比 M3 Max 还快?

在这个项目里,解释器是极度依赖单线程 IPC 和访存延迟的工作负载,几乎吃不到多核和内存带宽的红利。M3 Max 的 300GB/s 内存带宽和 A19 相比,在 CoreMark 这种小工作集场景下拉不开差距。所以 A19 小幅领先是正常的代际差异。

iSH 上 luajit -joff 比开启 JIT 还快

在 iSH 上,luajit -joffprimes_jit.lua 耗时 27530 ms,而开启 JIT 的要 46650 ms。推测原因:

我对 iSH 内部实现没有深入研究过,以上只是猜测。如果你熟悉 iSH 的源码,非常欢迎在评论区告诉我真正的成因。

与 Wasm3 的对比

作为另一个纯解释器的参考,我在同一台 MacBook 上用 wasm3 coremark.wasm 跑了 5 次:

也就是说,Podish 的纯计算性能已经可以比肩 Wasm3 了。


结语

Podish 证明了一件事:在 JIT 被禁止的平台上,一个足够贴近硬件、经过仔细 profile 和优化的解释器,完全可以跑出令人惊讶的性能。它不是万能的——复杂应用(如 Node.js)仍有明显瓶颈,多线程也无法利用多核——但对于命令行工具、脚本运行时和轻量编译任务来说,它已经是一个可用的日常工具。

如果你对项目感兴趣,欢迎访问 GitHub 或在浏览器里直接体验 Podish Web Demo


Edit page
Share this post on:

Next Post
Building Podish: An iOS-optimized Linux x86 container (faster than iSH)