Skip to content

[Performance] Use ArrayDeque for remaining LinkedList queues#516

Open
Jawnnypoo wants to merge 2 commits into
masterfrom
Jawnnypoo/linkedlist-to-arraydeque
Open

[Performance] Use ArrayDeque for remaining LinkedList queues#516
Jawnnypoo wants to merge 2 commits into
masterfrom
Jawnnypoo/linkedlist-to-arraydeque

Conversation

@Jawnnypoo

Copy link
Copy Markdown
Member

Summary

  • Replaces LinkedList with ArrayDeque in three remaining queue fields: FormulaRuntime.globalEffectQueue, FormulaManagerImpl.transitionQueue, and BatchImpl.updates.
  • Continues the precedent from [Performance] Use ArrayDeque for startEffect. #497 ([Performance] Use ArrayDeque for startEffect), extending it to the rest of the runtime's queue allocations.

Why

All three fields are used as FIFO queues — add-to-tail, remove-from-head, no random access, no mid-iteration mutation. ArrayDeque is strictly better for this workload:

  • Backed by a single circular array → much better cache locality than LinkedList's per-element heap-allocated Nodes.
  • No allocation per add (amortized O(1) when the backing array has room).
  • Same O(1) add/remove at both ends.

Compatibility notes

  • All three fields are private, so no external API change.
  • pollFirst() calls were switched to removeFirst() — every call site is already guarded by isNotEmpty(), so the throw-vs-return-null difference is not observable.
  • BatchImpl.updates is still passed as List<() -> Unit> to BatchManager.Executor.executeBatch; ArrayDeque implements MutableList, so this is unchanged.
  • LinkedList allowed mid-iteration mutation via ListIterator; ArrayDeque throws ConcurrentModificationException. None of the affected code paths use iterators — they all drain via removeFirst() in a while (isNotEmpty()) loop, which works the same on both.

Test plan

  • ./gradlew :formula:test passes locally.
  • CI green.

🤖 Generated with Claude Code

Replaces LinkedList with ArrayDeque in the three remaining queue fields:
globalEffectQueue (FormulaRuntime), transitionQueue (FormulaManagerImpl),
and BatchImpl.updates. ArrayDeque is backed by a circular array with
better cache locality and no per-element node allocation, which matches
the usage pattern here (add-to-tail / remove-from-head with no random
access or iterator mutation).

Continues the precedent set in #497 ([Performance] Use ArrayDeque for
startEffect).

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

Co-Authored-By: Claude <noreply@anthropic.com>
@carrotkite

carrotkite commented Jun 15, 2026

Copy link
Copy Markdown

JaCoCo Code Coverage 93.86% ✅

Class Covered Meta Status
com/instacart/formula/runtime/FormulaManagerImpl 90% 0%
com/instacart/formula/batch/BatchImpl 100% 0%
com/instacart/formula/FormulaRuntime 86% 0%

Generated by 🚫 Danger

@carrotkite

carrotkite commented Jun 15, 2026

Copy link
Copy Markdown

📊 Benchmark Comparison Report

Summary

  • Regressions: 0 ✅
  • Improvements: 23 🎉
  • Unchanged: 2

🎉 Performance Improvements

Benchmark Baseline Current Change
com.instacart.formula.benchmarks.TransitionQueueBenchmark.measure1 0.485 ± 0.006 us/op 0.338 ± 0.02 us/op -30.3% 🟢
com.instacart.formula.benchmarks.TransitionQueueBenchmark.measure100 0.485 ± 0.013 us/op 0.346 ± 0.013 us/op -28.8% 🟢
com.instacart.formula.benchmarks.TransitionQueueBenchmark.measure10000 0.495 ± 0.002 us/op 0.355 ± 0.003 us/op -28.3% 🟢
com.instacart.formula.benchmarks.GlobalEffectQueueBenchmark.measure100Effects 10.742 ± 0.239 us/op 7.926 ± 0.078 us/op -26.2% 🟢
com.instacart.formula.benchmarks.CallbackInitializationBenchmark.initializeNewCallbacks (callbackCount=50) 7.264 ± 1.829 us/op 5.595 ± 0.768 us/op -23.0% 🟢
com.instacart.formula.benchmarks.CallbackInitializationBenchmark.initializeNewCallbacks (callbackCount=10) 2.007 ± 0.226 us/op 1.57 ± 0.391 us/op -21.8% 🟢
com.instacart.formula.benchmarks.CallbackOverheadBenchmark.transitions (callbackCount=50) 17.644 ± 0.812 us/op 14.158 ± 0.335 us/op -19.8% 🟢
com.instacart.formula.benchmarks.ActionInitializationBenchmark.initializeNewActions (actionCount=25) 3.819 ± 0.102 us/op 3.077 ± 0.058 us/op -19.4% 🟢
com.instacart.formula.benchmarks.ActionInitializationBenchmark.initializeNewActions (actionCount=1) 0.84 ± 0.03 us/op 0.677 ± 0.05 us/op -19.4% 🟢
com.instacart.formula.benchmarks.CallbackOverheadBenchmark.transitions (callbackCount=10) 15.775 ± 0.146 us/op 12.723 ± 0.075 us/op -19.3% 🟢
com.instacart.formula.benchmarks.ActionCountBenchmark.stateChanges (actionCount=1) 15.397 ± 0.088 us/op 12.437 ± 0.085 us/op -19.2% 🟢
com.instacart.formula.benchmarks.ActionCountBenchmark.stateChanges (actionCount=100) 16.796 ± 0.118 us/op 13.585 ± 0.083 us/op -19.1% 🟢
com.instacart.formula.benchmarks.ActionCountBenchmark.stateChanges (actionCount=25) 15.715 ± 0.111 us/op 12.711 ± 0.134 us/op -19.1% 🟢
com.instacart.formula.benchmarks.ChildrenCountBenchmark.stateChanges (childrenCount=25) 15.86 ± 0.068 us/op 12.842 ± 0.13 us/op -19.0% 🟢
com.instacart.formula.benchmarks.TransitionBenchmark.transitions (depth=0) 15.228 ± 0.05 us/op 12.377 ± 0.07 us/op -18.7% 🟢
com.instacart.formula.benchmarks.TransitionBenchmark.transitions (depth=10) 15.535 ± 0.069 us/op 12.642 ± 0.117 us/op -18.6% 🟢
com.instacart.formula.benchmarks.TransitionBenchmark.transitions (depth=20) 15.973 ± 0.063 us/op 13.054 ± 0.088 us/op -18.3% 🟢
com.instacart.formula.benchmarks.ChildrenCountBenchmark.stateChanges (childrenCount=100) 17.502 ± 0.131 us/op 14.306 ± 0.175 us/op -18.3% 🟢
com.instacart.formula.benchmarks.ChildrenCountBenchmark.stateChanges (childrenCount=1) 15.27 ± 0.113 us/op 12.575 ± 0.156 us/op -17.6% 🟢
com.instacart.formula.benchmarks.ChildrenInitializationBenchmark.initializeNewChildren (childrenCount=1) 0.834 ± 0.047 us/op 0.696 ± 0.057 us/op -16.6% 🟢
com.instacart.formula.benchmarks.ChildrenInitializationBenchmark.initializeNewChildren (childrenCount=25) 3.93 ± 0.063 us/op 3.478 ± 0.191 us/op -11.5% 🟢
com.instacart.formula.benchmarks.ActionInitializationBenchmark.initializeNewActions (actionCount=100) 12.403 ± 0.108 us/op 11.029 ± 0.696 us/op -11.1% 🟢
com.instacart.formula.benchmarks.GlobalEffectQueueBenchmark.measure10Effects 0.766 ± 0.026 us/op 0.683 ± 0.009 us/op -10.8% 🟢
No significant changes (2 benchmarks)
Benchmark Baseline Current Change
com.instacart.formula.benchmarks.ChildrenInitializationBenchmark.initializeNewChildren (childrenCount=100) 12.999 ± 0.527 us/op 12.416 ± 0.37 us/op -4.5%
com.instacart.formula.benchmarks.GlobalEffectQueueBenchmark.measure1Effect 0.059 ± 0.002 us/op 0.055 ± 0.004 us/op -6.3%

Regressions: ±10% with non-overlapping confidence intervals. Improvements: ±10% change only.

Generated by 🚫 Danger

jasonostrander
jasonostrander previously approved these changes Jun 16, 2026

@jasonostrander jasonostrander left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM


private val isScheduled = AtomicBoolean(false)
private val updates = LinkedList<() -> Unit>()
private val updates = ArrayDeque<() -> Unit>()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Some things to consider with ArrayDeque

  • What is the initial memory allocation? Is that right for our use case?
  • I'm not sure if ArrayDeque sizes down. How likely is it that it spikes in items for a brief moment and then stays there long-term.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good callout. It sounds as though the initial memory size would be quite small, less than 100 bytes. As for growing infinitely, that is in fact a problem that this would introduce, though I think even in the thousands, it would be maybe a few kilobytes of memory usage.

We can size down manually. Let me look into that.

ArrayDeque never shrinks its backing array on its own — once a burst grows
the deque, that capacity stays pinned for the life of the holder. For the
two long-lived queues (FormulaRuntime.globalEffectQueue and
FormulaManagerImpl.transitionQueue), this means a transient cascading
transition could permanently inflate per-runtime / per-manager memory.

Fix:
- Give each deque a small initial capacity sized for the common case
  (8 effects, 4 deferred transitions, 4 batch updates) instead of relying
  on the stdlib's lazy default of 10.
- For the two long-lived queues, track the high-water mark and replace the
  deque with a fresh one at a natural drain-to-empty point if the burst
  exceeded a shrink threshold. BatchImpl.updates doesn't need this — the
  instance is single-shot and GC'd after execute().

Thread safety is unchanged: both queues are accessed only under the
existing SynchronizedUpdateQueue serialization, so field reassignment is
safe without @volatile (happens-before is established via the threadRunning
atomic).

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

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants