为什么会有这个对比
最近在研究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 KB | 427 ms | 1× |
| WebAssembly 应用(AOT) | Xtensa 机器码(AOT 编译) | 约 14 KB | 673 ms | 1.58× |
| WebAssembly 应用(解释) | WAMR 解释器跑字节码 | 约 14 KB | 4863 ms | 11.4× |
| Lua 脚本(朴素写法) | Lua 解释器跑源码 | 约 2 KB | 27283 ms | 63.9× |
| Lua 脚本(整数优化写法) | Lua 解释器跑源码 | 约 2 KB | 15703 ms | 36.8× |
| MicroPython 脚本 | 官方MicroPython 解释器跑源码 | 约 2 KB | 28247 ms | 66.2× |
结果不意外:解释执行比原生慢一到两个数量级。
版本与配置:
| 运行时 | 版本 | 配置 |
|---|---|---|
| MicroPython | v1.29.0 | 官方固件,Octal-SPIRAM 板 |
| Lua | 5.5 | 设备侧 LUA_32BITS=1 |
| WAMR | v2.4.0 | fast-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 算法,可按任意语言复现。
📚 相关阅读
- 动态扩展运行时架构研究:elf_loader 基座架构与 ESP-Brookesia 对比 —— 四种执行方式背后的架构设计:elf_loader 单一基座 vs Brookesia 多后端,以及本文实测数据对应的选型结论