为什么会有这个对比

最近在研究ESP32S3的基础系统和应用分离的开发架构,比较常见的应用层主要有这几种"形态":

形态本质典型用途
原生动态模块(elf)编译成目标芯片机器码性能热区、协议栈、编解码
WebAssembly 应用(wasm)编译成字节码,由运行时解释执行沙箱隔离、第三方维护、生态复用
Lua 脚本(lua)直接解释源码热更新、胶水逻辑、低频控制
Micropython 脚本(py)直接解释源码与lua一致

它们共用一个应用包 / 部署 / 加载框架,但运行性能差多少,很少有人用同一把尺子量过。本文就用一个最经典的基准(计算 1000 位圆周率)在同一个设备上给四种形态做了一次公平对决。

测试怎么做到公平

  • 同一台设备:ESP32-S3 R8(Xtensa内核,主频 240 MHz,8MB PSRAM, 16MB Flash)
  • 同一种运行环境:应用都加载到PSRAM执行
  • 同一个算法:四个实现是同一份 Rabinowitz-Wagon spigot 算法的直译
  • 同一位数:都算 1000 位,输出结果逐位一致(小数位完全相同)
  • 同一计时口径:只统计纯计算耗时,不含加载、初始化、日志输出

除了micropython直接在官方固件上运行,其他四种测试脚本各自封装成应用包,由设备启动计划自动拉起,开机即跑、跑完即退,互不干扰。

使用的算法:Rabinowitz-Wagon spigot

“spigot”(水龙头)算法是一类逐位生成 π 的算法,像水龙头一样每拧一下流出一位数字。Rabinowitz-Wagon 是其中最经典的一种:用一组长整数数组做"长除法流水线",每轮归约出一位。

伪代码(以 1 为下标):

输入 n(要生成的位数)
len = floor(10 * n / 3)
a[1..len] 全部初始化为 2
nines = 0, predigit = 0

对 j = 1..n:
    q = 0
    从 i = len 递减到 1:
        x = 10 * a[i] + q * i
        d = 2 * i - 1
        a[i] = x mod d
        q = floor(x / d)
    a[1] = q mod 10
    q = floor(q / 10)

    如果 q == 9:nines = nines + 1
    否则如果 q == 10:
        输出 (predigit + 1)
        连续输出 nines 个 0
        predigit = 0, nines = 0
    否则:
        输出 predigit(第一轮跳过)
        predigit = q
        连续输出 nines 个 9
        nines = 0
最后输出 predigit

技巧:多算 3 位再截断。spigot 算法在出现 9 传播时可能"欠一位",多算几位再丢弃,能保证前 1000 位绝对正确。四个实现都按 n = 1003 计算、取前 1000 位。

这段算法逻辑很简单,但恰好是纯整数密集计算:没有浮点、没有内存分配热点(只有一组长数组)、没有外部调用,最能拉开"解释执行 vs 机器码"的差距。

实测结果

形态执行方式载荷大小1000 位 π 耗时相对原生
原生动态模块Xtensa 机器码约 2.2 KB427 ms1×
WebAssembly 应用(AOT)Xtensa 机器码(AOT 编译)约 14 KB673 ms1.58×
WebAssembly 应用(解释)WAMR 解释器跑字节码约 14 KB4863 ms11.4×
Lua 脚本(朴素写法)Lua 解释器跑源码约 2 KB27283 ms63.9×
Lua 脚本(整数优化写法)Lua 解释器跑源码约 2 KB15703 ms36.8×
MicroPython 脚本官方MicroPython 解释器跑源码约 2 KB28247 ms66.2×

结果不意外:解释执行比原生慢一到两个数量级。

版本与配置:

运行时版本配置
MicroPythonv1.29.0官方固件,Octal-SPIRAM 板
Lua5.5设备侧 LUA_32BITS=1
WAMRv2.4.0fast-interp / AOT(PC 侧 Xtensa-LLVM 工具链 + 设备端 AOT 运行时)

均跑在同一块 ESP32-S3 @ 240 MHz。五个实现为同一份 spigot 算法的直译(MicroPython 和 Lua 整数优化版同样用了整数写法 // 与列表预分配),输出逐位一致。

为什么差这么多

1. 原生:机器码直接跑(1×)

编译期就把算法翻译成 Xtensa 指令,运行时 CPU 直接执行,没有中间层。寄存器和内存访问都是最优的,427 ms 是这台芯片在 -O2 下的"物理上限"。

2. WebAssembly:字节码 + 解释器(11.4×)

wasm 应用先被编译成静态类型的字节码,运行时由 WAMR(WebAssembly Micro Runtime)逐条解释执行:

  • 类型在编译期就定死了(i32 就是 i32),解释器不需要做动态类型判断,可以生成相对紧凑的解释循环(fast-interp);
  • 但仍要:取指令 → 查操作码分派 → 按语义执行 → 更新程序计数器,每条指令都有固定开销;
  • 数组是一块连续线性内存,下标访问接近原生(这是 wasm 能"只慢 11 倍"而不是更慢的关键)。

3. Lua:源码 + 动态表(63.9×)

Lua 解释器直接读源码逐条执行,负载比较重:

  • 每次 a[i] 都要查表:a 是 table(哈希表 / 数组混合结构),下标访问要走一遍结构判断与可能的重哈希;
  • 每个数字都是对象:10 * a[i] + q * i 里的每个中间量都要新建/回收 number 对象(即便走整型路径也有装箱开销);
  • 解释循环本身比 wasm 的解释器更厚:操作数要逐字段解析,函数调用要过调用栈与闭包检查。

三重叠加,于是 Lua 是 wasm 的 3.2 倍慢,是原生的 37(整数优化写法)~64 倍(朴素写法)。

4. MicroPython:全对象 + 分代 GC(66.2×)

MicroPython 与 Lua 同为"源码级解释 + 动态类型",但对象模型更重:

  • 一切都是对象:整数、列表元素都是堆上的 mp_obj_t,每次算术都要经过对象头 + 类型分派(mp_binary_op),没有 Lua 那种"标签值 + 寄存器 VM"的轻量整数路径;
  • 列表下标访问是完整方法调用链:a[i-1] 要过 mp_obj_subscr → 列表类型分派 → 索引检查,比 Lua 的 luaV_fastgeti 宏展开重得多;
  • GC 压力:[2] * LEN 和 res.append() 产生大量堆对象,分代 GC 周期性回收,纯计算循环也躲不开。

于是 MicroPython 比优化后的 Lua 还慢 1.8 倍,与朴素写法的 Lua 基本打平。

白话解释:原生是"把答案写死在芯片里",wasm 是"边翻字典边念",Lua 是"一边查字典一边还要确认每个字的意思",MicroPython 是"每个字都要拆开看是哪国语言再翻译"。

那么,AOT 值得做吗?

  • AOT 把解释器的 4863 ms 压到 673 ms(1.58×),比原先估的"顶多压到 1 秒出头"还好,纯计算场景已接近原生;
  • 但 wasm 应用里真正花时间的往往不是模块内计算,而是宿主桥调用(bs_log、共享内存、文件 IO),这些跨边界调用 AOT 一点都加速不了;
  • 代价是工具链:需要 PC 侧 Xtensa-LLVM 交叉编译链 + 设备端 AOT 运行时(官方 LLVM 没有该后端),这是一条长期维护线。

结论:性能热区直接写原生 .so。wasm 的取舍点在于沙箱 / 第三方维护 / 生态复用,而不是性能。

选型原则(一句话版)

需求选谁
每周期执行的算法 / 协议解析 / 编解码原生动态模块(10~60 倍差距不是优化能补的)
沙箱隔离 / 第三方维护 / 复用 wasm 生态WebAssembly(解释 ~11 倍,AOT 后可到 ~1.6 倍)
热更新脚本 / 胶水逻辑 / 低频控制Lua 或 MicroPython(接受 ~40~70 倍代价)

附录:参考实现与复现方法

为便于读者复现,这里贴出算法核心(pi_digits / pi_spigot),各语言代码都是设备上实际运行的完整实现。

文中出现的 bs_*、esp_timer_get_time()、sys.millis()、time.ticks_ms() 都是设备侧的 宿主接口(日志 / 计时 / 输出),换成你平台上的等价物即可;malloc/free 在 wasm 版里接的是宿主堆,桌面运行时(如 wasmtime)下可直接用标准分配器。

复现方法

  • 四份实现均为 Rabinowitz-Wagon spigot 的直译,唯一差异是语言语法,无任何算法级改动;
  • 计时只包裹主循环,排除字符串拼接与日志;
  • 结果用权威 π 前 100 位核对一致后,再对比四位输出全等。

附录 A:原生 C 实现

/*
 * 原生 C 模块计算 1000 位圆周率(Rabinowitz-Wagon spigot 算法)
 *
 * 计时用 esp_timer_get_time()(微秒),仅包裹纯计算主循环。
 */

#include <stdint.h>
#include <stdlib.h>
#include <string.h>

#include "esp_log.h"
#include "esp_timer.h"

static const char *TAG = "pi1000_c";

/* Rabinowitz-Wagon spigot:逐位生成 pi 的数字(res[0] = 整数位 3) */
static uint32_t pi_digits(uint32_t n, uint8_t *res)
{
    uint32_t len = 10 * n / 3;
    uint32_t *a = (uint32_t *)malloc(len * sizeof(uint32_t));
    if (!a)
        return 0;
    for (uint32_t i = 0; i < len; i++)
        a[i] = 2;

    uint32_t nines = 0, predigit = 0;
    uint32_t r = 0;

    for (uint32_t j = 1; j <= n; j++) {
        uint32_t q = 0;
        for (uint32_t i = len; i >= 1; i--) {
            uint32_t x = 10 * a[i - 1] + q * i;
            uint32_t d = 2 * i - 1;
            a[i - 1] = x % d;
            q = x / d;
        }
        a[0] = q % 10;
        q = q / 10;

        if (q == 9) {
            nines++;
        } else if (q == 10) {
            res[r++] = (uint8_t)(predigit + 1);
            for (uint32_t k = 0; k < nines; k++)
                res[r++] = 0;
            predigit = 0;
            nines = 0;
        } else {
            if (j > 1)
                res[r++] = (uint8_t)predigit;
            predigit = q;
            if (nines > 0) {
                for (uint32_t k = 0; k < nines; k++)
                    res[r++] = 9;
                nines = 0;
            }
        }
    }
    res[r++] = (uint8_t)predigit;
    free(a);
    return r;
}

__attribute__((visibility("default")))
void app_main(void)
{
    const uint32_t DIGITS = 1000;

    /* 多算 3 个,截断取前 1000 位 */
    const uint32_t N = DIGITS + 3;
    uint8_t *digits = (uint8_t *)malloc((N + 8) * sizeof(uint8_t));

    int64_t t0 = esp_timer_get_time();
    uint32_t nd = pi_digits(N, digits);
    int64_t dt = esp_timer_get_time() - t0;

    if (!digits || nd < DIGITS + 1) {
        ESP_LOGE(TAG, "pi1000_c: failed (nd=%u)", nd);
        free(digits);
        return;
    }

    /* 拼接:整数位 3 + 1000 位小数(digits[0] = 3) */
    char *pi = (char *)malloc(DIGITS + 8);
    if (pi) {
        int o = 0;
        pi[o++] = '3';
        pi[o++] = '.';
        for (uint32_t i = 1; i <= DIGITS; i++)
            pi[o++] = (char)('0' + digits[i]);
        pi[o] = '\0';
        ESP_LOGI(TAG, "%s", pi);
        free(pi);
    }

    ESP_LOGI(TAG, "pi1000_c: %u digits in %lld ms", DIGITS, (long long)(dt / 1000));
    ESP_LOGI(TAG, "pi1000_c: done");

    free(digits);
}

附录 B:WebAssembly(Rust)实现

//! wasm 应用计算 1000 位圆周率(Rabinowitz-Wagon spigot 算法)
//!
//! 计时用宿主提供的 bs_uptime(微秒),仅包裹纯计算主循环。

#![no_std]
#![no_main]

extern crate alloc;

use alloc::{format, string::String, vec, vec::Vec};
use core::panic::PanicInfo;

/* ---------------- 宿主 import(env 模块) ---------------- */
extern "C" {
    fn bs_log(json: *const u8, len: i32) -> i32;
    fn bs_result_len(id: i32) -> i32;
    fn bs_result_copy(id: i32, out: *mut u8, len: i32) -> i32;
    fn bs_result_free(id: i32);
    fn bs_delay_ms(ms: i32);
    fn bs_should_stop() -> i32;
    fn bs_uptime(j: *const u8, l: i32) -> i32;

    fn malloc(size: usize) -> *mut u8;
    fn free(p: *mut u8);
}

/* ---------------- 分配器:接宿主堆 ---------------- */
struct HostAlloc;
unsafe impl core::alloc::GlobalAlloc for HostAlloc {
    unsafe fn alloc(&self, layout: core::alloc::Layout) -> *mut u8 {
        malloc(layout.size())
    }
    unsafe fn dealloc(&self, ptr: *mut u8, _: core::alloc::Layout) {
        free(ptr)
    }
}

#[global_allocator]
static HOST_ALLOC: HostAlloc = HostAlloc;

/* ---------------- 工具 ---------------- */
fn log_msg(msg: &str) {
    let mut esc = String::with_capacity(msg.len() + 8);
    for c in msg.chars() {
        if c == '"' || c == '\\' {
            esc.push('\\');
        }
        esc.push(c);
    }
    let json = format!("{{\"level\":\"i\",\"tag\":\"pi1000_rust\",\"msg\":\"{}\"}}", esc);
    unsafe {
        let id = bs_log(json.as_ptr(), json.len() as i32);
        bs_result_free(id);
    }
}

/// 调 bs_uptime 返回开机微秒数(解析 {"ok":true,"us":N})。
unsafe fn uptime_us() -> u64 {
    let id = bs_uptime(b"{}".as_ptr(), 2);
    if id < 0 {
        return 0;
    }
    let n = bs_result_len(id);
    let mut buf = vec![0u8; n.max(0) as usize];
    if n > 0 {
        bs_result_copy(id, buf.as_mut_ptr(), n);
    }
    bs_result_free(id);
    let s = core::str::from_utf8(&buf).unwrap_or("");
    let key = "\"us\":";
    if let Some(i) = s.find(key) {
        let rest = &s[i + key.len()..];
        let digits: String = rest
            .chars()
            .take_while(|c| c.is_ascii_digit())
            .collect();
        if let Ok(v) = digits.parse() {
            return v;
        }
    }
    0
}

/* ---------------- spigot 算法 ---------------- */

/// 生成 pi 的数字序列(res[0] = 整数位 3,res[1..] = 小数位)。
fn pi_digits(n: usize) -> Vec<u8> {
    let len = 10 * n / 3;
    let mut a = vec![2u32; len];
    let mut nines: u32 = 0;
    let mut predigit: u32 = 0;
    let mut res = Vec::with_capacity(n);

    for j in 1..=n {
        let mut q: u32 = 0;
        let mut i = len;
        while i >= 1 {
            let x = 10 * a[i - 1] + q * (i as u32);
            let d = (2 * i - 1) as u32;
            a[i - 1] = x % d;
            q = x / d;
            i -= 1;
        }
        a[0] = q % 10;
        q = q / 10;

        if q == 9 {
            nines += 1;
        } else if q == 10 {
            res.push((predigit + 1) as u8);
            for _ in 0..nines {
                res.push(0);
            }
            predigit = 0;
            nines = 0;
        } else {
            if j > 1 {
                res.push(predigit as u8);
            }
            predigit = q;
            if nines > 0 {
                for _ in 0..nines {
                    res.push(9);
                }
                nines = 0;
            }
        }
    }
    res.push(predigit as u8);
    res
}

/* ---------------- main ---------------- */

#[no_mangle]
pub extern "C" fn main() -> i32 {
    const DIGITS: usize = 1000;

    unsafe {
        let t0 = uptime_us();
        let digits = pi_digits(DIGITS + 3); // 多算几个,截断取前 1000 位
        let dt = uptime_us() - t0;

        // 拼接:整数位 3 + 1000 位小数(digits[0] = 3)
        let mut pi = String::with_capacity(DIGITS + 2);
        pi.push_str("3.");
        for i in 1..=DIGITS {
            pi.push((b'0' + digits[i]) as char);
        }
        log_msg(&pi);
        log_msg(&format!("pi1000_rust: {} digits in {} ms", DIGITS, dt / 1000));
    }

    log_msg("pi1000_rust: done");
    0
}

#[panic_handler]
fn panic(_: &PanicInfo) -> ! {
    core::arch::wasm32::unreachable()
}

附录 C:Lua 的朴素实现,使用math.floor算除法

-- 计算 1000 位圆周率(Rabinowitz-Wagon spigot 算法)
-- 参考 Simeon Simeonov 的 Pascal 原版(pi_spigot),本机已与权威前 100 位核对。

local sys = require("sys")

local DIGITS = 1000

-- Rabinowitz-Wagon spigot:逐位生成 pi 的数字(含整数位 3 在最前)。
local function pi_spigot(n)
    local len = math.floor(10 * n / 3)
    local a = {}
    for i = 1, len do a[i] = 2 end
    local nines, predigit = 0, 0
    local res = {}
    for j = 1, n do
        local q = 0
        for i = len, 1, -1 do
            local x = 10 * a[i] + q * i
            local d = 2 * i - 1
            a[i] = x % d
            q = math.floor(x / d)
        end
        a[1] = q % 10
        q = math.floor(q / 10)
        if q == 9 then
            nines = nines + 1
        elseif q == 10 then
            res[#res + 1] = predigit + 1
            for _ = 1, nines do res[#res + 1] = 0 end
            predigit, nines = 0, 0
        else
            if j > 1 then res[#res + 1] = predigit end
            predigit = q
            if nines > 0 then
                for _ = 1, nines do res[#res + 1] = 9 end
                nines = 0
            end
        end
    end
    res[#res + 1] = predigit
    return res
end

-- 计算 + 计时(多算几个,截断取前 DIGITS 位,避免 9 传播导致少位)
local t0 = sys.millis()
local digits = pi_spigot(DIGITS + 3)
local dt = sys.millis() - t0

-- 拼接输出:整数位 3 + DIGITS 位小数(res[1] = 整数位 3)
local pi = "3."
for i = 2, DIGITS + 1 do
    pi = pi .. tostring(digits[i])
end

print("pi1000_lua: " .. pi)
print(string.format("pi1000_lua: %d digits in %d ms", DIGITS, dt))
print("pi1000_lua: done")

附录 D:Lua 的优化实现,使用//算整数除法

-- 计算 1000 位圆周率(Rabinowitz-Wagon spigot 算法)
-- 参考 Simeon Simeonov 的 Pascal 原版(pi_spigot),本机已与权威前 100 位核对。

local sys = require("sys")

local DIGITS = 1000

-- Rabinowitz-Wagon spigot:逐位生成 pi 的数字(含整数位 3 在最前)。
local function pi_spigot(n)
    local len = (10 * n) // 3          -- 整数整除(不超 int32)
    local a = {}
    for i = 1, len do a[i] = 2 end
    local nines, predigit = 0, 0
    local res = {}
    local r = 0                         -- 本地计数器替代 #res + 1
    for j = 1, n do
        local q = 0
        for i = len, 1, -1 do
            local x = 10 * a[i] + q * i -- 全整数,int32 内安全
            local d = 2 * i - 1
            a[i] = x % d                -- 整数取模
            q = x // d                  -- 整数整除(OP_IDIV)
        end
        a[1] = q % 10
        q = q // 10
        if q == 9 then
            nines = nines + 1
        elseif q == 10 then
            r = r + 1; res[r] = predigit + 1
            for _ = 1, nines do r = r + 1; res[r] = 0 end
            predigit, nines = 0, 0
        else
            if j > 1 then r = r + 1; res[r] = predigit end
            predigit = q
            if nines > 0 then
                for _ = 1, nines do r = r + 1; res[r] = 9 end
                nines = 0
            end
        end
    end
    r = r + 1; res[r] = predigit
    return res
end

-- 计算 + 计时(多算几个,截断取前 DIGITS 位,避免 9 传播导致少位)
local t0 = sys.millis()
local digits = pi_spigot(DIGITS + 3)
local dt = sys.millis() - t0

-- 拼接输出:整数位 3 + DIGITS 位小数(res[1] = 整数位 3)
local pi = "3."
for i = 2, DIGITS + 1 do
    pi = pi .. tostring(digits[i])
end

print("pi1000_lua: " .. pi)
print(string.format("pi1000_lua: %d digits in %d ms", DIGITS, dt))
print("pi1000_lua: done")

附录 E:MicroPython 实现

# MicroPython 计算 1000 位圆周率(Rabinowitz-Wagon spigot 算法)
# 计时用 time.ticks_ms(),仅包裹纯计算主循环。

import time as _t

DIGITS = 1000

def pi_spigot(n):
    LEN = (10 * n) // 3          # 整数整除
    a = [2] * LEN                # 预分配列表
    nines = 0
    predigit = 0
    res = []
    for j in range(1, n + 1):
        q = 0
        for i in range(LEN, 0, -1):
            x = 10 * a[i - 1] + q * i
            d = 2 * i - 1
            a[i - 1] = x % d
            q = x // d
        a[0] = q % 10
        q = q // 10
        if q == 9:
            nines += 1
        elif q == 10:
            res.append(predigit + 1)
            res.extend([0] * nines)
            predigit = 0
            nines = 0
        else:
            if j > 1:
                res.append(predigit)
            predigit = q
            if nines > 0:
                res.extend([9] * nines)
                nines = 0
    res.append(predigit)
    return res

t0 = _t.ticks_ms()
digits = pi_spigot(DIGITS + 3)
dt = _t.ticks_diff(_t.ticks_ms(), t0)
pi = "3." + "".join(str(d) for d in digits[1:DIGITS + 1])
print("pi1000_py: " + pi)
print("pi1000_py: %d digits in %d ms" % (DIGITS, dt))
print("pi1000_py: done")

本文数据来自一次真实设备实测。算法为公开的经典 spigot 算法,可按任意语言复现。

📚 相关阅读