Skip to content

Conversation

@spawnia
Copy link
Collaborator

@spawnia spawnia commented Dec 5, 2025

Resolves #1803

Follow up to #1790

spawnia and others added 2 commits December 5, 2025 15:11
@spawnia
Copy link
Collaborator Author

spawnia commented Dec 5, 2025

Summary

This PR optimizes memory usage for deferred execution while preserving the DataLoader pattern that was broken in #1790.

Problem

PR #1790 attempted to optimize memory but broke DataLoader batching - batch loads executed incrementally before all IDs were collected, causing null values with 600+ items.

Solution

Reduces memory usage by replacing closure-based queue storage with direct object/array storage:

  1. Store executor on promise instance - $executor property instead of closure context
  2. Queue promise objects directly - enqueue($this) instead of enqueue(function() {...})
  3. Use arrays for waiting handlers - [$promise, $onFulfilled, $onRejected, $state, $result] instead of closures
  4. Explicit cleanup - $task->executor = null and unset($task) enable GC during long queues

Tests Added

Verification

spawnia and others added 7 commits December 9, 2025 14:26
Store promise references and arrays in the queue instead of closures
to reduce memory footprint. This approach:

- Stores the executor callable on the SyncPromise instance rather than
  capturing it in a closure
- Queues the promise reference itself (smaller than a closure)
- Uses arrays instead of closures in enqueueWaitingPromises()
- Preserves DataLoader batching pattern (no premature queue processing)

Key changes:
- Add $executor property to SyncPromise for storing the executor
- Update __construct() to store executor and queue $this
- Update runQueue() to handle promise refs, arrays, and callables
- Add processWaitingTask() for processing waiting promise arrays
- Update enqueueWaitingPromises() to queue arrays instead of closures

This maintains full compatibility with the DataLoader pattern since
all Deferred executors are still queued BEFORE any are executed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@spawnia spawnia marked this pull request as ready for review December 9, 2025 15:58
@spawnia
Copy link
Collaborator Author

spawnia commented Dec 9, 2025

@shadowhand @zimzat @joshbmarshall Can you give this a review?

@joshbmarshall
Copy link

I loaded it up on the system that was having problems with v15.27.0

It still has issues with v15.27.0 tag, but this is working for me at 1954f81

For my query, the memory usage is identical for this pull request as for master branch. But I think it's because I have nested resolvers:

event date > event > event dates

I think it's waiting for all the event dates to be queued before resolving, which is how I'd expect it to be. I don't think that can be managed!

@zimzat
Copy link
Contributor

zimzat commented Dec 11, 2025

@spawnia This looks promising; I don't see any issues with load order or breaking batches.

Below are some extra thoughts based on my own trial-error:

The different signatures in the queue feels concerning. I had started experimenting with the idea of switching out the anonymous functions with invokable classes. Your implementation of $this->executor wins out over the cost of instantiating a new class, while a new class for the waiting queue item as a replacement for the array is a little more efficient there, so the two patterns combined reduces peak usage slightly further.

Using SplFixedArray for $this->waiting[] = SplFixedArray would also get half way to the same peak memory reduction.

https://gist.github.com/zimzat/310e9121225ef18640fad2f4ab675ad4

Reduces peak memory usage by using SplFixedArray instead of regular
arrays for promise waiting queue items. SplFixedArray has a lower
memory footprint for fixed-size data.

Addresses feedback from @zimzat in #1805 (comment)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@spawnia
Copy link
Collaborator Author

spawnia commented Dec 11, 2025

@zimzat I did follow up on your suggestion to use SplFixedArray, seems like a useful optimization.

Can you elaborate on what specifically you find concerning? The methods in SyncPromise are not marked with @api, so they are not supposed to be part of the public API.

@zimzat
Copy link
Contributor

zimzat commented Dec 14, 2025

Can you elaborate on what specifically you find concerning?

The implicit array shape versus an explicit type check of a named and structured class. It's not a big deal, especially here and internally to the class (like you've said, this isn't technically a public API). The if (x) elseif (y) elseif (z) felt verbose compared to the simplicity of the queue always being callable(). ¯\_(ツ)_/¯

spawnia and others added 4 commits December 15, 2025 08:31
Store waiting task data (callbacks, state, result) directly on the child
SyncPromise instead of wrapping in SplFixedArray. This simplifies the
queue's generic type from `SplQueue<self|SplFixedArray<mixed>>` to
`SplQueue<self>`.

Memory benchmark shows ~6.5% reduction for Deferred + then() chains:
- Single Deferred: 528 → 608 bytes (+80, due to new properties)
- Single then(): 888 → 792 bytes (-96, no SplFixedArray wrapper)
- 5000 Deferred + then(): 9.6MB → 9.0MB (-620KB net savings)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@spawnia
Copy link
Collaborator Author

spawnia commented Dec 15, 2025

@zimzat I did another round which should hopefully alleviate your concern. Benchmarks look good too, what do you think?

@zimzat
Copy link
Contributor

zimzat commented Dec 15, 2025

@spawnia That looks nice; a simple signature, self-contained handling, and still a massive drop in memory usage in the simple many deferred (no then) scenario. Thanks!

@spawnia
Copy link
Collaborator Author

spawnia commented Dec 17, 2025

Benchmark Results Summary

Ran benchmarks comparing this branch against master on PHP 8.3.6.

Key Changes

  • Promise state changed from strings ("pending", "fulfilled", "rejected") to integers (0, 1, 2)
  • Extracted SyncPromiseQueue to a dedicated class
  • Moved $executor handling from SyncPromise constructor to Deferred
  • Added comprehensive DeferredBench for tracking deferred execution performance

Memory Improvements

Benchmark Master Branch Change
benchManyNestedDeferreds 19.39 MB 17.67 MB -8.9%
bench1000Chains 10.08 MB 9.72 MB -3.6%
benchManyDeferreds 6.45 MB 6.09 MB -5.6%

Execution Time

Benchmark Master Branch Change
benchManyNestedDeferreds 17,801 μs 16,651 μs -6.5%
bench1000Chains 4,042 μs 3,919 μs -3.0%
benchManyDeferreds 673 μs 922 μs +37%
benchChain100 97 μs 145 μs +50%
benchChain5 6.0 μs 10.1 μs +68%
benchSingleDeferred 0.9 μs 1.2 μs +38%

Summary

The branch achieves significant memory savings (up to 9%) for heavy deferred workloads while also improving execution time for the most demanding benchmarks (benchManyNestedDeferreds, bench1000Chains).

The microbenchmarks with simple promise chains show some CPU overhead, likely due to structural changes that benefit memory at a small cost to simple cases. For real-world GraphQL queries with many nested deferreds, the branch performs better overall.

@spawnia
Copy link
Collaborator Author

spawnia commented Dec 17, 2025

Next steps for me:

  • try this in my real-world projects
  • try this in nuwave/lighthouse

@shmax
Copy link
Contributor

shmax commented Dec 17, 2025

Lookin' good!

@mfn
Copy link
Contributor

mfn commented Dec 17, 2025

I tested this in a private project with a couple of thousand GraphQL tests, no issues. Can't say anything about real life impact though.

@spawnia
Copy link
Collaborator Author

spawnia commented Dec 18, 2025

Lighthouse and a large project of mine work.

@spawnia spawnia marked this pull request as ready for review December 18, 2025 07:28
@spawnia spawnia merged commit 0685035 into master Dec 18, 2025
15 checks passed
@spawnia spawnia deleted the optimize-deferred branch December 18, 2025 16:27
@spawnia
Copy link
Collaborator Author

spawnia commented Dec 18, 2025

@joshbmarshall
Copy link

very early at the moment, but a number of my sites have gone down because of this update. I've reverted back to v15.28.0 for now which gets them going. I'll have to get you details but this is causing issues

@joshbmarshall
Copy link

My sentry is reporting:

Call to undefined method GraphQL\Deferred::runQueue()

vendor/overblog/dataloader-php/lib/promise-adapter/src/Adapter/WebonyxGraphQLSyncPromiseAdapter.php in Overblog\PromiseAdapter\Adapter\WebonyxGraphQLSyncPromiseAdapter::await at line 115

    public function await($promise = null, $unwrap = false)
    {
        if (null === $promise) {
            Deferred::runQueue();
            SyncPromise::runQueue();
            $this->cancellers = [];
            return null;
        }
        $promiseAdapter = $this->getWebonyxPromiseAdapter();

Comment on lines +20 to +22
public const PENDING = 0;
public const FULFILLED = 1;
public const REJECTED = 2;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👋🏾 I think this is BC break

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See https://github.com/webonyx/graphql-php?tab=readme-ov-file#versioning:

Elements that belong to the public API of this package are marked with the @api PHPDoc tag. Those elements are thus guaranteed to be stable within major versions. All other elements are not part of this backwards compatibility guarantee and may change between minor or patch versions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Getting null instead of a list of items for v15.27.0

8 participants