Porffor: A from-scratch experimental ahead-of-time JS engine
porffor.dev
porffor.dev
The really cool optimizations come from compiling TypeScript, or something close to it. You could use types to get enormous gains. Anything without typing gets the default slow JS calls. Interfaces can get reduced to vtables or maybe even straight calls, possibly on structs instead of maps. You could have an Int and Float type that degrade into Number that just sit inside registers.
The main problem is that both TS and V8 are fast-moving, non-standard targets. You could only really do such a project with a big team. Maintaining compatibility would be a job by itself.
It'd be nice of TS at least allowed for specifying types like integer, allowing some of the newer TS aware runtimes could take advantage of the additional info, even if the main TS->JS compilation just treated `const val: int` the same as `const val: number`.
I wonder if a syntax like
const counter: Number<int>
would be acceptable. const counter = UInt64(42);
The current state of the art is const counter = BigInt.asUintN(64, 42);Because JS code can arbitrarily modify a type, any language trying to specify what the outputs of a function can be also has to be Turing complete.
There are of course still plenty of types that TS doesn't bother trying to model, but it does try to cover even funny cases like field names going from kebab-case to camelCase.
const Integer = {
[Symbol.customMatcher]: (value) => [Number.parseInt(value)]
}
const Integer(counter) = 42.56;
// counter === 42
[1] https://github.com/tc39/proposal-extractorsAs a simple example - TypeScript doesn’t distinguish between integers and floats; they’re all just numbers. So all array accesses need casting. A TypeScript designed to aid static compilation likely would have that distinction.
But the big elephant in the room is TypeScript’s structural subtyping. The nature of this makes it effectively impossible for the compiler to statically determine the physical structure of any non-primitive argument passed into a function. This gives you worse-than-JIT performance on all field access, since JITs can perform dynamic shape analysis.
Suppose you have some interface with fields a and c. If your function takes in an object with that interface and operates on the c field, what you want is to be able to do is compile that function to access c at “the address pointed to by the pointer to the object, plus 8” (assuming 64-bit fields). Your CPU supports such addressing directly.
Because of structural subtyping, you can’t do that. It’s not unrecognized member. But your caller might pass in an object with fields a, b, and c. This is entirely idiomatic. Now c is at offset 16, not 8. Because the physical layout of the object is different, you no longer have a statically known offset to the known field.
Outside of this, you can unify the types. You would take every interface used to access the object and create a new type that has all of the members of both. You can then either create vtables or monomorphize where it is used in calls.
At any point that analysis cannot determine the actual underlying shape, you drop to the default any.
In practice v8 does exactly what you're saying can't be done, virtually all the time for any hot function. What you mean to say is that typescript type declarations alone don't give you enough information to safely do it during a static compile step. But modern JS engines, that track object maps and dynamically recompile, do what you described.
> This gives you worse-than-JIT performance on all field access, since JITs can perform dynamic shape analysis.
We're talking about using types to guide static compilation. Dynamic recompilation is moot.
A compiler might be able to wring some things out of it (I'm skeptical about obviouslynotme's suggestions in a cousin comment, but they seem insistent) or suppress some checks if you're happy with a segfault when someone did a cast...but it's just not a type system like, say, C's, which is more rigid and thus gives the compiler more to work with.
This isn't because of `any` either. The type system itself deliberately has holes in it. So any language that uses TypeScript type annotations to generate faster/smaller code is opening itself to miscompiling code and segfaults, etc.
Do you have any examples off the top of your head?
For TypeScript, they list an example with `instanceof`:
https://counterexamples.org/polymorphic-union-refinement.htm...
In the playground:
https://www.typescriptlang.org/play/?#code/GYVwdgxgLglg9mABA...
It yields no warnings or errors at compile stage but gives runtime error based on a wrong flow-based type inference. The crux of it is that something can be a Bird (with "fly" function) but can also have any other members, like "swim" because of structural typing (flying is the minimum expected of a Bird). The presence of a spurious "swim" member in the bird causes tsc to infer in a conditional that checks for a "swim" member that the animal must be a Fish or Human, when it is not (it's just a Bird with an unrelated, non-function "swim" member).
type Fish = { swim: () => void };
type Bird = { fly: () => void };
type Human = { swim?: () => void; fly?: () => void };
function move(animal: Fish | Bird | Human) {
if ("swim" in animal) {
// TSC infers wrongly here the presence of "swim" implies animal must be a Fish or Human
onlyForFishAndHumans(animal);
} else {
animal;
}
}
function onlyForFishAndHumans(animal: Fish | Human) {
if (animal.swim) {
animal.swim(); // Error: attempt to call "not-callable".
}
// (receives bird which is not a Fish or Human)
}
const someObj = { fly: () => {}, swim: "not-callable" };
const bird: Bird = someObj;
move(bird);
// runtime error: [ERR]: animal.swim is not a function
[1] https://www.typescriptlang.org/docs/handbook/2/narrowing.htm... if (typeof animal.swim === 'function') {....} type Bird = { id: number };
type Human = { swim?: () => void; id: number };
function move(animal: Bird | Human) {
onlyForHumans(animal);
}
function onlyForHumans(swimmer: Human) {
if (swimmer.swim) {
swimmer.swim();
}
}
const someObj = { id: 1, swim: "not-callable" };
move(someObj);
`someObj` gets casted as `Bird`, then `animal` gets casted as `Human`. Unfortunately unions here are indeed unsound.As as workaround you could add a property `type` (either `"bird"` or `"human"`) to both `Bird` and `Human`, then TypeScript would be able to catch it.
const foo: string[] = ['foo']
// inferred as string
// though it is undefined
const bar = foo[1]Can you name a single language that is used for high-performance software and whose type system is sound? To speed up the process, note that none of the obvious candidates have sound type systems.
Also, I'm not too familiar with JVM bytecode, but if I load i64 in two registers and then perform floating point addition on these registers, does the type system prevent me from compiling/executing the program?
Can you say more about "proven to be sound"? Are you talking about a sound type system?
https://docs.oracle.com/javase/specs/jvms/se22/html/jvms-2.h...
JVM is a stack not register machine and yes the type system will prevent that from running. It will fail verification.
instructionIsTypeSafe(fadd, Environment, _Offset, NextStackFrame, ExceptionStackFrame) :-
validTypeTransition(Environment, [float, float], float, StackFrame, NextStackFrame),
exceptionStackFrame(StackFrame, ExceptionStackFrame).
Fun fact: Said type system has a 'top' type that is both the top type of the type system as well as the top half of a long or double, as those two actually take two values while everything else, including references, is only one value. Made some sense when everything was 32 bit, less so today.https://www.scala-lang.org/api/3.x/docs/blog/2016/02/17/scal...
Soundness in all of those languages involves a mixture of compile-time and runtime checks. Most of the safety comes from the static checking, but there are a few places where the compiler defers checking to runtime and inserts checks to ensure that it's not possible to have an expression of type T successfully evaluate to a value that isn't an T.
TypeScript doesn't insert any runtime checks in the places where there are holes in the static checker, so it isn't sound. If it wasn't running on top of a JavaScript VM which is dynamically typed and inserts checks everywhere, it would be entirely possible to segfault, violate memory safety, etc.
Yup. Milner's "can't go wrong", progress and preservation, etc.
> You cannot just reply "well there are a few runtime checks so it's all good."
Sure I can. I really like how Shriram Krishnamurthi describes soundness in Programs and Programming Languages [1]. I can't think of a better definition for soundness than:
"The central result we wish to have for a given type-system is called soundness. It says this. Suppose we are given an expression (or program) e. We type-check it and conclude that its type is t. When we run e, let us say we obtain the value v. Then v will also have type t."
The "we obtain the value v" part is critical. If an expression of type e doesn't produce a value at all (it terminates or throws an exception), then we have also satisfied soundness.
Indeed, note that he also says:
"Any rich enough language has properties that cannot be decided statically (and others that perhaps could be, but the language designer chose to put off until run-time to reduce the burden on the programmer to make programs pass the type-checker). When one of these properties fails—e.g., the array index being within bounds—there is no meaningful type for the program. Thus, implicit in every type soundness theorem is some set of published, permitted exceptions or error conditions that may occur. The developer who uses a type system implicitly signs on to accepting this set."
A term like "soundness" for a programming language should be useful. We could, for example, define "evenality" as a property of programming languages where we say that a language whose built-in atomic types have names that are all an even number of letters has evenality and other languages don't. That's a well-defined concept and we could neatly partition extant languages into whether they have evenality or not. But who cares?
When it comes to soundness, the above definition from PAPL is useful for (at least) two concrete reasons:
1. When a user is reading code, if they see an expression has some type T, they can safely reason that any value the expression evaluates to will have type T and when they are reasoning about code surrounding that expression, they can rely on that fact.
2. Likewise, when a compiler is compiling code, it can safely assume that if an expression has type T, then all subsequent code that depends on the value of that expression can assume it has type T. The compiler can optimize safely and correctly based on that assumption.
Neither of these properties require that all type checks are performed at compile time. If the runtime throws an exception on out of bounds array indices, that still correctly preserves the soundness invariant that the type of an array element access is the type of the array element. The reader might have to think about the fact that the expression could throw. But they don't have to think about it evaluating to the wrong type.
If that's not your definition of soundness and you require a sound language to have zero runtime checks, then I'm not aware of any widely-used language that meets that requirement, nor do I see how it's a particularly useful term.
Note that it's not the case that every language is sound according to the above definition. C, C++, TypeScript, and Dart 1.0 (but not 2.0 and later) are all unsound. In the first two, it's possible to completely reinterpret memory as another type which leads to the majority of software security issues in the world. In the latter two, the only reason that doesn't happen is because the underlying execution environment doesn't rely on the static types of expressions at all.
[1] https://papl.cs.brown.edu/2020/safety-soundness.html#%28part...
class A {}
class B1 extends A {}
class B2 extends A {}
A[] arr = new B1[1];
arr[0] = new B2();
In the above example only way that assigning an array of `B1` to a variable typed as an array of `A` is if only valid `B1` objects are ever put into it, at which point there's no reason not to just have the variable typed as a `B1` array. It still will compile fine though!The dynamic checks in the Java case are are a well-defined and narrowly-targeted part of the language semantics- you get an exception on mismatched array writes, out-of-bounds access, etc., but when an expression produces a value it always matches its type.
TypeScript defers these kinds of type system violations to the underlying JavaScript engine, which makes things work out (sometimes with an exception, but sometimes just proceeding with a value that doesn't match the expression's type) using precisely the dynamism we wanted to get rid of. And this can leak out and cause arbitrarily-far-away parts of the program not to match their types, either.
It's more specific than that; the discussion is about writing an ahead-of-time compiler, which necessarily wouldn't be running on a JavaScript engine. The compiler could just as easily emit code that always throws a runtime exception instead of emitting an equivalent to whatever the JavaScript would do.
Microsoft's AOT compiler for MakeCode, via C++.
[1]: https://www.microsoft.com/en-us/research/publication/static-...
AssemblyScript (https://www.assemblyscript.org/) is a TypeScript dialect with that distinction
Did the site change? Or am I missing something? :)
I wonder how much performance gain you expect to achieve. For simple CPU-bounded tasks, C/Rust/etc is roughly three times as fast as v8 and Julia, which compiles full scripts and has good type analysis, is about twice as fast. There is not much room left. C/Rust/etc can be much faster with SIMD, multi-threading and fine control of memory layout but an AOT JS compiler might not gain much from these.
If we could bundle everything to native that would completely change the game since as good as bun's cold start is, you can't beat running straight native with a small binary.
I've spent a bit of time trying to review each, so hopefully this analysis will be useful for some readers. What are the main commonalities and differences between Static Hermes and Porffor?
* They both aim for JS test262 conformance [1]
* Porffor supports both Native and Wasm outputs while Static Hermes is mainly focused on Native outputs for now
* Porffor is self-hosted (Porffor is written in pure JS and can compile itself), while Static Hermes relies on LLVM
* Porffor currently doesn't support async/promise/await while Static Hermes does (with some limitations)
* Static Hermes is written in C++ while Porffor is mainly JS
* They both support TypeScript (although Static Hermes does it through transpiling the TS AST to Flow, while Porffor supports it natively)
* Static Hermes has a fallback interpreter (to support `eval` and other hard-to-compile JS scenarios), while Porffor only supports AOT compiling (although, as I commented in other thread here, it maybe be possible to support `eval` in Porffor as well)
In general, I'm excited to see if this project can gain some traction so we can speed-up Javascript engines one the Edge!
Context: I'm Syrus, from Wasmer [3][1] https://github.com/facebook/hermes/discussions/1137
Is there a technical reason why?
Eg.
Promise.then(() => console.log(“a”));
console.log(“b”)
must log [“b”, “a”] and not [“a”, “b”].Admittedly, it is not our focus, we are focusing mainly on React Native, where WASM doesn't make sense.
The most important feature of Static Hermes is our type checker, which guarantees runtime soundness.
Porffor is very interesting, I have been watching it for some time and I am rooting for it.
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...
This might make sense for a complete implementation, but in reality...no one would implement a useless API. Also, if truly no one used it, it would simply not exist (e.g. `with` in module mode), a new implementation of some spec will always pick and choose based on utility and use-cases.
If some change were required which introduced a regression on some Test262 tests, it could cause the version number to regress as well. This means Porffor cannot have both a version number which increases monotonically and the ability to introduce necessary changes which cause Test262 regressions
[0] https://github.com/CanadaHonk/porffor?tab=readme-ov-file#ver...
I have been working on providing quickjs with more node compatible API through llrt [1] for embedding into applications for plugins.
> 1+1
2
> help
Uncaught ReferenceError: help is not defined at exports.<computed> [as main] (file:///opt/homebrew/lib/node_modules/porffor/compiler/wrap.js:494:19) at REPLServer.run (file:///opt/homebrew/lib/node_modules/porffor/runner/repl.js:98:27) at bound (node:domain:432:15) at REPLServer.runBound [as eval] (node:domain:443:12) at REPLServer.onLine (node:repl:927:10) at REPLServer.emit (node:events:532:35) at REPLServer.emit (node:domain:488:12) at [_onLine] [as _onLine] (node:internal/readline/interface:416:12) at [_line] [as _line] (node:internal/readline/interface:887:18)
Any language that allows generating and interpreting its own code at runtime will have the "eval problem". From some other comments here, it sounds like Porffor's solution is to simply ignore it.
It was called hiphop-php, then they eventually gave up, before creating hhvm on a complete new concept.
After HHVM proved that its JIT compilation engine was faster than their HipHop AOT attempt, did they decided to focus only on HHVM going forward.
I wont be surprised to see a SPA framework that uses Porffor once it is more mature, or even the major ones using it as part of their tooling.
WASM is the next step after SPA's essentially.
If you have never touched Blazor, I recommend you check it out via youtube video if you don't do any C#, it is impressive. Kudos to Microsoft for it. I have had 0 need or use for JavaScript since using it.
[0] https://bun.sh/
import * as squint_core from 'squint-cljs/core.js'; console.log("hello");
*or at least, in theory
And most devs stay away from eval for well-deserved security reasons.
AFAIK the only option is to include an interpreter.
I'd recommend checking the work on making SpiderMonkey emit Wasm as a backend