共计字数:2k 预计阅读用时:7 分钟

关于如何优化 Scratch 虚拟机的运行速度思路

开学一周了,也有一阵子没有写过博客了,正好最近在自制 ScratchScript ,脑袋里想到了些关于优化性能的思路,就写在这里留作记忆了。

注:下文仅为个人见解,如果有误请在评论区指出,谢谢!

本文仅在 SteveXMH 的个人博客 里发布,请勿复制本文!

短板在哪?

看过 Scratch 3.0 虚拟机源码 的都知道,官方 VM 为了扩展性和与 ScratchBlocks 的交互性整出了不少的接口,一环套一环,而且为了适应不严谨的 JavaScript 和难看的扩展文档在扩展调用方面做了大堆的语法糖,执行一个模块的逻辑都没有 Scratch 2.0 的虚拟机 来的简洁,尽管 3.0 相对于 2.0 性能快了不少,但是如果如此设计虚拟机,无疑会大大降低可以利用的性能。

也许是我的错觉,因为 3.0 的虚拟机代码附上了大量的注释,尽可能地解释了代码的功能(虽然我还是看不懂(巨雾))

立刻掀了 3.0 的调度器?

至少人力成本是不允许的,代码的耦合度太强,强行替换会出现连锁反应,所以我的想法是基于原有调度器添加类似预编译的功能,从我的思路想法上应该不会有太大的难度,难点在于如何进行预编译。

预编译?

我的思路来源于这个预编译型虚拟机 forkphorus 和它的原版 phosphorus,二者都是通过将模块预先转译成 JavaScript 后再执行,所以我有想把类似的转移方式塞进 3.0 运行时里(不是把 forkphorus 照搬进去!)。

举个栗子:

这是我们的运动模块中的 前进 ( )步 的模块实现:

1
2
3
4
5
6
7
8
9
10
11
class Scratch3MotionBlocks {
// ...
moveSteps (args, util) {
const steps = Cast.toNumber(args.STEPS);
const radians = MathUtil.degToRad(90 - util.target.direction);
const dx = steps * Math.cos(radians);
const dy = steps * Math.sin(radians);
util.target.setXY(util.target.x + dx, util.target.y + dy);
}
// ...
}

首先,因为我们的运行时在注册模块的时候,会给每个模块的实现使用 Function.bind 固定 this,所以导致了不能直接对执行模块实现使用 Function.toString 获取原代码,不然只会返回 [Native Code],所以为了能够在不修改原代码的前提下添加预编译的方案,也许只有添加一个新的属性用于描述用于嵌入的代码块了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Scratch3MotionBlocks {
// ...
get jitcodes () {
return {
// 对照模块实现的 moveSteps
motion_movesteps: `
const steps = Cast.toNumber(args.STEPS);
const radians = MathUtil.degToRad(90 - util.target.direction);
const dx = steps * Math.cos(radians);
const dy = steps * Math.sin(radians);
util.target.setXY(util.target.x + dx, util.target.y + dy);
`
}
}
// ...
}

当然也许我们还可以在注册前使用 Function.toString 获取到函数实现代码,获得了实现代码之后,我们就可以尝试进行组合然后使用 new Function(code) 生成新包装的函数了。但是如此直接复制的话难免会变得繁杂,因为原本的实现参数并不能直接用,那么我们可不可以用一种特殊的参数以暴露所需的接口呢?

答案是肯定的,每个模块实现里的第二个参数是一个抽象对象,可以让模块实现访问到很多的接口。我们也可以定义一个 util 参数来包装几个 JIT 代码会用到的东西,之后的实现代码都可以用得到。

顺带一提,我们的 JIT 代码会自深到浅进行预编译,类似于脏检查,如果有代码出现了变动,就只更新那一段的 JIT 代码,然后和原来已经生成过的 JIT 代码重新组合,减少函数生成消耗。

模块参数?

这个的难度还是有一点的,为了最大化性能肯定要做成内嵌形式的模块才可以正确获取参数,但是那么多的数据转换难免有点受不了,所以我想到了一个字符串的替换方案,用[=[InputName]=]用来表达要嵌入的参数,我们便可以把上面的 JIT 代码如此改动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Scratch3MotionBlocks {
// ...
get jitcodes () {
return {
// 对照模块实现的 moveSteps
motion_movesteps: `
const steps = util.Cast.toNumber([=[STEPS]=]);
const radians = util.MathUtil.degToRad(90 - util.target.direction);
const dx = steps * Math.cos(radians);
const dy = steps * Math.sin(radians);
util.target.setXY(util.target.x + dx, util.target.y + dy);
`
}
}
// ...
}

之后在生成的时候,根据模块的原型进行字符串转换,把常量或者嵌入的模块嵌入进去即可,但是部分地方可能需要注意一下优先级和语法问题,例如运算符的代码转换,还有数学函数的代码转换,如果遇到了同代码的不同模块实现,为了能更好的进行嵌入可以把 JIT 代码做成一个函数,传递模块参数并返回对应的 JIT 代码,可以更好的优化 JIT 代码量。

舞台刷新?

正常情况下我们的舞台角色通常会需要移动,等待,或者用画笔绘制图像,所以我们需要在一些模块里嵌入一种用于暂时停止的功能,以防止无止境的等待卡顿或不让出的问题。

我的想法是使用 ES2017 的 AsyncFunction 来做到这个效果(IE:我####),然后在 util 参数里加上一个 yield 异步函数,调用时把目前的状态(当前的模块ID)和是否需要等待舞台刷新传递给调度器,然后调度器返回一个 Promise 并保存 resolve 函数到当前线程里,等待调度器处理完其他线程并更新舞台后再调用,即可继续运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Scratch3ControlBlocks {
// ...
get jitcodes () {
return {
// 对照模块实现的 wait
control_wait: `
if (util.stackTimerNeedsInit()) {
const duration = Math.max(0, 1000 * util.Cast.toNumber([=[DURATION]=]));

util.startStackTimer(duration);
util.runtime.requestRedraw();
await util.yield({
currentBlockId: 'abcd',
waitStageUpdate: true
});
} else if (!util.stackTimerFinished()) {
await util.yield({
currentBlockId: 'abcd',
waitStageUpdate: true
});
}
`
}
}
// ...
}

代码块?

代码块例如 如果< >那么{ } 模块需要嵌入一段代码树,其实在 VM 里分支模块树本质也是一个参数,所以我们可以使用 SUBSTACK,SUBSTACK2 等参数进行嵌入,甚至我们可以自己定义一个 NEXT 参数用于连接下一个模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Scratch3ControlBlocks {
// ...
get jitcodes () {
return {
// 对照模块实现的 wait
control_if: `
const condition = Cast.toBoolean([=[CONDITION]=]);
if (condition) {
[=[SUBSTACK]=];
}
[=[NEXT]=];
`
}
}
// ...
}

那扩展作者也得一个个加 JIT 代码?

我们是基于原有的调度器进行改造,如果遇到没有 JIT 代码的模块,我们就按照原版的执行方式去执行,直到下一个模块包含了 JIT 代码才会继续调用。

热更新?

难免会有刁难人的事情,比如执行时代码被更换了(模块拖拽过了),但是介于 VM 是单线程的,且模块更新和调度器不会并行运行,所以这个问题不会有太大的影响。一般情况下我们的 JIT 代码会在让出和结束执行时记录下当前的模块ID,所以我们可以检测当前线程的当前模块是否存在,是否有可用的 JIT 代码供调用,还有后面是否有其他模块链接,等等,如果没有 JIT 代码,就按照原版的调度器来执行代码,如果有则继续工作,我们的 JIT 代码会在热更新时自动更新,不会有出现 JIT 代码没有完全生成就被执行的问题。

说的好听,做起来还是很累。。。

学生党依然是没有时间的,所以上面提供的思路,也许真正的大佬就可以做得出来,期待能有这样的新 VM 出现。

有什么意见建议就发在评论区吧!周末我会回来看的哦!