← All issues

Async function promise optimization for no-await bodies

19dc01a

Source/JavaScriptCore/bytecompiler/NodesCodegen.cpp

+ if (isEmptyBody()) {
+ ASSERT(!usingDeclarationCount());
+ ASSERT(!hasAwaitUsingDeclaration());
+ RefPtr<RegisterID> newResolvedPromise = generator.moveLinkTimeConstant(nullptr, LinkTimeConstant::newResolvedPromise);
+ CallArguments resolveArgs(generator, nullptr, 1);
+ generator.emitLoad(resolveArgs.thisRegister(), jsUndefined());
+ generator.emitLoad(resolveArgs.argumentRegister(0), jsUndefined());
+ RefPtr<RegisterID> result = generator.newTemporary();
+ generator.emitCall(result.get(), newResolvedPromise.get(), NoExpectedFunction, resolveArgs, divot, divot, divot, DebuggableCall::No);
+ generator.emitWillLeaveCallFrameDebugHook();
+ generator.emitReturn(result.get());
+ break;
+ }

Source/JavaScriptCore/dfg/DFGByteCodeParser.cpp

+ case NewResolvedPromiseIntrinsic: {
+ if (argumentCountIncludingThis < 2)
+ return CallOptimizationResult::DidNothing;
+ insertChecks();
+ Node* argument = get(virtualRegisterForArgumentIncludingThis(1, registerOffset));
+ setResult(addToGraph(NewResolvedPromise, Edge(argument)));
+ return CallOptimizationResult::Inlined;
+ }
+
+ case NewRejectedPromiseIntrinsic: {
+ if (argumentCountIncludingThis < 2)
+ return CallOptimizationResult::DidNothing;
+ insertChecks();
+ Node* argument = get(virtualRegisterForArgumentIncludingThis(1, registerOffset));
+ setResult(addToGraph(NewRejectedPromise, Edge(argument)));
+ return CallOptimizationResult::Inlined;
+ }

In JSC, every async function is desugared to a state machine that wraps its body in an implicit promise. Historically, this promise is allocated at function entry; when the body finishes, fulfillPromiseWithFirstResolvingFunctionCallCheck is called to settle it — handling thenable unwrapping, spec-mandated identity checks, and PerformPromiseThen. For no-await async functions, the promise is guaranteed to settle synchronously at the first return point, so the pre-allocation and full settle path are pure overhead.

This commit detects no-await bodies at compile time via isEmptyBody() and emits a deferred newResolvedPromise/newRejectedPromise call at return time instead. New DFG and FTL IR nodes (NewResolvedPromise, NewRejectedPromise) back this path so the JIT can fold it into a direct fulfilled-promise allocation when the return value is proven non-object.

Before (all async functions):
  Entry
   └─► allocate Promise (heap) ─────────────────────────────────┐
   └─► run body                                                  │
         └─► fulfillPromiseWithFirstResolvingFunctionCallCheck ──┘
              (thenable check, identity guard)
              └─► return pre-allocated Promise

After (no-await async functions):
  Entry
   └─► run body  (no promise allocated)
         ├── normal return ──► NewResolvedPromise(value)
         │                      (DFG: direct fulfilled-promise alloc if non-object)
         └── throw       ──► NewRejectedPromise(error)
              └─► return freshly-settled Promise

Async functions with no await are common in real codebases (adapters, delegation wrappers, trivial async APIs), and eliminating the upfront promise allocation and full settle path reduces both GC pressure and throughput overhead for callers that chain .then() on such functions.

🔒

New JIT nodes and fast-path classification logic for async functions — several edge cases are worth security investigation.

Subscribe to read more