Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.

Optimizing Protoflux

From Resonite Wiki

This page is mainly to collect optimizations that are particular to Protoflux. Optimizations that apply broadly such as binary search also apply to protoflux if they can be implemented, and also can belong here as protoflux-specific implementations.

The material on this page will mainly help to improve the performance of flux by something in the very rough ballpark of 2x, sometimes much more, sometimes much less.

Architectural improvements such as binary search should generally be sought before lower-level optimizations. However the overhead cost of a complex algorithm like Quicksort can significantly overshadow potential improvements if the dataset is not commensurately large.

Statements made on this page should be based on sound testing as much as possible, along with images showing what was tested.

Before Optimizing

Optimizations are usually best avoided until a given node group works reliably with all it's needed features, because they can dramatically increase the amount of work needed to fix a bug or add a behavior later.

However knowledge of optimizations beforehand can be very beneficial overall and can avoid the need to rework a system that becomes too inefficient.

The user experience

Optimizing for the fun of the game is very different from optimizing with some tangible goal in mind. Fun often leads to useful knowledge but if there is a need to be met then optimizations need to be effective in context of that need.

In that sense, it's most valuable to understand and frame any optimization in terms of the user experience, so that optimizations can be understood in context and applied effectively.

Paradoxical optimizations

When optimization is applied with respect to the user experience, perception becomes important. This means that some optimizations can be paradoxical.

A user will feel like their FPS is low if their attention is on an object that is moving sporadically. So reducing CPU time by throttling updates often produces the opposite of the desired result.

The user does not typically feel their CPU usage, but experiences the world through what they are shown. As a result, if an optimization causes visuals to become stuttery, the user will perceive it as being unoptimized even if the CPU usage is objectively lower.

Conversely, if an object becomes visually smoother, the user will perceive it as being optimized even if the CPU usage is objectively higher.

For this reason, naively applying optimizations without testing will sometimes produce the opposite desired result.

In Practice

The most effective rule for optimization is to test your work. Many optimizations will fail when implemented blindly.

Find the most easy and effective tools for doing those tests so that you naturally rely more on testing than on dead-reckoning. Also make sure that every optimization does not break the core operation your flux is performing.

Anything written here isn't really confirmed to be effective until it is verified in application, because optimizations are usually very picky and not always straightforward to apply correctly. A singular detail will often make or break a high-effort optimization like unrolling a for loop into drives.

An effective tool for performance testing takes the fastest path to statistical significance and reduces/equalizes confounders.

Optimizing drives

One of the largest performance factors in Protoflux before considering node-level performance is effects of change tracking.

This is most common when drives and FireOnX nodes are used because they have the ability to evaluate only when one of their inputs detect a change. There is usually no need to evaluate when none of the inputs have changed, unless the result is non-deterministic.

However this is a double-edged sword because if the Protoflux scheduler falsely believes that the flux group needs to evaluate every update, the slowdown can be dramatic depending on what the drive is evaluating. An example would be a drive that depends on a slot search.

This is usually triggered by nodes internally marked with the ContinuouslyChanging flag. If a drive depends on any such node, it will be forced to evaluate every update. The impact of this can be reduced by using short-circuiting nodes such as Conditional to skip branches if the user can provide a condition to exclude them. The condition should be easy to evaluate.

Many basic nodes have the ContinuouslyChanging flag, so the likelihood that any node group will have one is high, and a lot drive-based flux will be continuously updating when it doesn't need to be. Protoflux generally errs on the side of evaluating something that didn't need it rather than skipping something that does.

FireOnX type nodes also trigger on change events in the same manner as drives and benefit from similar optimizations in their input tree.

Overall, if drive-style updates are appropriate for a given project, drives will usually be most optimal because they will only evaluate when necessary, and when they do there will be fewer nodes to evaluate because the update logic is part of the drive.

In short:

  • Drives/FireOnX update continuously when there is a ContinuouslyChanging node in their input tree.
  • Drives/FireOnX can be "turned off" by disabling the slot they are in.
  • Conditionals and other short-circuiting nodes can be used to disable a heavy input branch on a drive that is continuous, if a simple condition can be found that works. For example, disabling friction calculations on an object when it isn't moving.
    • This is often unsuccessful in practice due to parasitic evaluation and disrupting arithmetic compaction, but can help when one input is dramatically heavier.

Checking for continuously running protoflux

To check whether a drive or FireOnX node is scheduled for continuous updates, the node slot can be opened in the inspector. On the node component there will be yellow text describing whether the node is updating continuously or not. If the node is selected physically, the inspector will open on the UI slot and the node itself can be found on it's parent.

General Optimizations

Although there are many exceptions, the most general rule of thumb for optimizing protoflux is that CPU time is monotonic with the number of nodes being evaluated (which includes loops or branching).

That is to say that while the CPU time of individual nodes may be different, adding a node will almost always increase CPU time and removing a node will almost always reduce it.

So all else being equal, less nodes or less impulses means faster protoflux.

This relationship can also be observed to apply for node inputs and outputs, where a MultiAdd node will have about the same CPU time as multiple Add nodes. All else being equal, reducing the number of node inputs or outputs will improve CPU time even if those inputs/outputs are blank, unless the output of the node is unconnected in which case it will never evaluate.

Many optimizations observe exceptions to this rule:

  • If the nodes added/removed change the branching of the protoflux, this can disrupt arithmetic compaction as well as change the number of nodes being evaluated.
  • Replacing many fast nodes with a few slow nodes can reduce speed (this is not really an exception, just what happens when nodes are replaced rather than just added or just removed).
  • Conditional or Multiplex nodes will often be slower than using more nodes because these nodes perform branching in Protoflux. Test replacing them with something like ZeroOne --> ValueLerp if possible.
  • Shared value/object trees trigger parasitic evaluations (Github #4175) and will often be slower than separating them with duplicate ValueSources or duplicating the tree entirely for each evaluating node.
  • Switching between types to use a convenient operator may reduce speed by preventing arithmetic compaction. So in effect, adding another arithmetic operator to an arithmetic tree is almost free compared to something like casting to an int. If an operation can be done entirely in float arithmetic while eliminating branching protoflux nodes, it is often faster to do so although the resulting protoflux may be harder to read.

The performance impact of relays

Relays do have a minor impact on protoflux performance and this has been described by Frooxius as an optimization that hasn't been written yet. Additionally, if there are enough relays to increase the component count significantly, then loading time will be worse for a large project.

Impulse relays are much less costly than value/object relays in terms of performance.

If many relays are used to improve the layout of flux, they can contribute significantly to the flux performance. There is a tool that deletes all relays which can be used when determining the impact.

Potentially the largest impact from a single relay can occur due to Github issue #4175. Most pulsed node outputs such as the index on a For loop, DynamicImpulseReceiverWithValue or RaycastOne implement those outputs as local variables.

Local variables do not seem to trigger parasitic writes if they are shared between multiple write nodes, but if a relay is inserted between the sharing writes and the local output, it will trigger parasitic evaluations. Depending on what is parasitically evaluated, the impact on performance can be dramatic.

Relay performance is the subject of Github Issue #4174.

Protoflux Evaluation Overhead

The overhead of evaluating a single protoflux node is typically in the very rough ballpark of 100x the CPU cost of it's core operation.

What this means is that while operations like Sine and SquareRoot are conventionally thought of as heavy operations, their contribution to protoflux CPU time is actually very tiny.

Because the vast majority of CPU time is per-node overhead, it is actually often more efficient to use a single Sine node than it is to construct another solution using multiple arithmetic nodes, despite Sine technically being a heavy CPU instruction.

It's just that the act of evaluating the protoflux node is already doing the effective CPU work of say 100 Sine operations. In effect, operations like Sine give more "bang per buck" in Protoflux if they reduce node count, because each new node is like adding 100 sine operations along with the node's core operation, even if it is just a RoundToInt or something similar.

Warm-up effects

If a node group is unused and then activated once, it's CPU time can be in the ballpark of 5-100x longer than if it were used every update.

There appears to be a warm-up cost to evaluating a node group that increases rapidly with how many updates it has been unused.

This cost does not seem to warrant a timer to keep any node group "warm", and is likely the result of already optimized memory management by the DotNet runtime, such that node groups are probably already as "warm" as they reasonably can be.

Protoflux performance testing

The user FPS will usually be the undisputed last word in the performance of any given object.

If the Protoflux in question can be looped enough times to affect the FPS, then this will be a good indicator of performance as long as nothing else in the world is causing FPS changes that may be mistaken for being caused by the object. In some cases the typical variance of user FPS will make it encumbering to use in practice.

However any optimization that requires huge loops to begin affecting the user FPS is already falling into the category of a micro-optimization, if that flux will normally only be run once or a few times per frame.

If optimizing is being done for a desired result rather than for the fun of the game, then it is important to place one's attention on the most important performance issues first. This usually means identifying the main performance offenders before attempting any optimizations.

Warnings about performance testing

If in doubt, and especially if you're new to this kind of testing, it is worth making sure all important work is saved before starting testing. Even if no mistakes were made, heavy tests can sometimes trigger obscure bugs or hardware instability.

Testing with high loop iterations can be dangerous if a very heavy operation accidentally gets looped 1 million times, such as an impulse display. Reflexively pulling out an impulse display on flux that is being looped 1 million times will usually crash you. This may require constant vigilance while certain tools are in use!

Due to Github #4175, pulling out output displays on flux that is being tested will reduce it's performance parasitically. It's common to accidentally change a benchmark result by forgetting connected displays.

Tools

The user experience is ultimately the last word in the importance or effectiveness of an optimization. FPS is usually most representative of this, but suffers from many confounders so can cause a lot of misdirection in practice due to outside variations.

For objects that don't affect rendering, the engine time will usually be a better indicator with less variance and responding more to the object being tested.

For quickly profiling Protoflux groups with a high confidence and fast edit loop, Sync time is usually preferred.

While SyncTime will reliably show differences between permutations of the same flux, it will not show the scale of that difference in terms of the user's experience. One can endlessly pare down sync time on flux that will never meaningfully affect the user's framerate, so it is up to you to keep track of the context and scale of optimizations when using a tool like this.

Sync time tester used on GreatestCommonDivisor
A sync time tester used to test the speed of GreatestCommonDivisor. Time can be counted in "ticks" of 100ns or in another measure with the available Time nodes. Displays originating from the stores will be local, which is useful to see different users' performance if used with LocalUpdate as an update source.

Sync Time

Because the UTCNow node will provide an immediate impulse timestamp with 100ns resolution on most hardware (or 500 clock cycles at 5GHz), it can be used to find the time difference between execution reaching different parts of flux.

An important exception is when execution breaks from the flux, such as when a delay is encountered or when a node triggers an independent action that occurs at a different update step (like a tween). In these cases, the delayed tasks need their own timers, and if the actions can't be timed in protoflux then the performance will have to be measured via an engine time, render time or frametime metric.

Most nodes will evaluate before 100ns, so using UTCNow for this usually requires timing a loop of say 256 operations so that the total execution time will be countable at 100ns resolution.

This provides a usable value for the CPU time of the flux that runs between UTCNow timestamps.

Since single-shot runs can have a very high variance especially due to warm-up time, it's usually better to repeat the loop and collect a combined or average result of more than 100 iterations.

Note that if variable values evolve during the test, they may become invalid values especially for a node group that was only meant to run once. This can cause the performance of a group to change misleadingly from one test to the next, so it is good to ensure that variables change in the same fashion as the intended use case.

Single-shot variance

  • Individual runs can vary a lot, especially due to warm-up behavior of a cold node group, and even more in the case of a user activating the flux with a button.
  • This is usually improved by smoothing or averaging, which incurs an acquisition time but greatly improves the trustworthiness of the result if enough data is collected. If a result is very different between runs, the number of averages likely needs to increase.

Unrealistic for single-shot use case

  • If an operation won't actually be run more than once, then looping it say 256 times will allow it to be compared with versions of itself but will give a per-iteration speed that is optimistic for it's real use scenario. On the other hand, if it needs to loop 256 times for an effect to be observed, then it's unlikely to have any noticeable effect if it only runs once when a user presses a button. On average, the warm-up time still appears to be monotonic with respect to the number of operations so in practice this kind of testing is still effective, although it calls into question whether the flux being tested affects the user frametime meaningfully.
  • This is usually not an issue since improvements typically still apply regardless of warm-up behavior unless the warm-up behavior is specifically affected.

Change tracking

  • If data model fields are being written within the benchmarked flux, change tracking will incur a cost on each write of a data model field if their values were changed. Naively looping flux that was meant to run once often means that these fields will stop changing or become invalid numbers. If the values become invalid and then are not reset between runs they will produce misleading results. So care is usually warranted to keep value changes realistic to the real use scenario. As an example, data model fields will be measurably faster if their value is not changed, which can happen if the source value becomes invalid or an accumulator variable overflows.

Engine Time

If your flux does not affect rendering or the rendering cost is not related to protoflux operations, then engine time can be used to determine performance when UTCNow cannot directly capture the work period.

Engine time and Render time metrics are available through the PerformanceMetrics component, but as of the Splittening, the engine time metric is broken, and the render time metric does not work on all platforms/GPU drivers. It can be emulated for the most part in protoflux by using UTCNow and excluding the rendering period of the update loop. The UTCNow timestamps can't be guaranteed to enclose all protoflux activity as the update order cannot be reliably controlled, but they can be executed as close to the rendering period boundaries as possible which usually captures most protoflux and engine activity.

Because engine time includes all engine activity in principle, many things are happening during that period which are not related in any way to the actions being tested. For this reason it has a high variance and must be very carefully averaged. Lock-in amplification can be used to lower the noise floor considerably.

Eliminating confounders

If there is still a concern about variance between runs ruining benchmark results, such as CPU frequency, then it can help a lot to test two copies of the flux in the same time period and compare them. This way they will be affected by the same factors. If you do this, be aware that the first copy of some flux in a given world can perform differently than subsequent copies and that you may need to run the comparison between 2nd and 3rd copies while leaving the first copy out of the comparison.

Lock-in amplification

The simplified idea of a lock-in amplifier is that you have an on/off test period, and you compare the results of the on period to the results of the off period. If you test for long enough, the difference will eventually be statistically significant. Because the periods alternate, outside variations in test time will tend to even out between them.

The alternation rate of the lock-in amplifier is important. It should alternate fast enough that temporary increases or decreases in engine time get spread across multiple on/off periods and contribute more equally to them, thus canceling themselves out when the periods are compared.

Each test period will benefit from discarding a certain number of frames at it's beginning because when the object being tested is turned on or off, the toggle cost is often strong and asymmetric. Objects that affect rendering in particular can have a strong toggle cost.

A good way to handle this is to plot the average of on/off frametime waveforms and observe the toggle cost visually. This will leave much less room for doubt as to how many frames need to be discarded.

The Optimization process

In many cases, a flux group can be pulsed by an Update or LocalUpdate node along with a sync time tester so that the performance can be observed with a very short edit cycle. Performance can be quickly improved this way.

In some cases the flux can't be edited live without a major risk of crashing, so the flux and performance tester will be button activated.

It is best to keep track of the measured performance in between edits, otherwise one can fail to notice when a regression has occurred, or when the point of diminishing returns has been reached. Keeping track of optimizations and their results will keep you from going in circles and help narrow down the best permutation of a flux group when there are many possible implementations.

Confirming optimizations

Measurement noise or procedural errors often cause false positives for optimizations. There are some tests that an optimization should be able to pass:

  • Reversibility
    • If the optimization is reversed, the result should go back
  • Persistence
    • If the object is saved and spawned in a different world, the optimization should still be effective and reversible. Flux rebuilds can be inconsistent, so the final word on performance comes from freshly spawned flux.
  • Agnostic to test harness
    • The optimization should by reversible and persistent in any relevant test harness.
      • Not all testing tools behave the same. Sometimes it's the tool itself, sometimes the flux can interact with a given testing method.
    • A good way to test this is to have two copies of the flux in two sync testers, and then swap the flux between testers. Sometimes this will expose a performance difference that is unrelated to the flux being tested.

Uncurated list of optimizations

This is a place for known optimizations to go if they can't or don't need to fit into a broader narrative.

Parasitic evaluation tests
Some tests of parasitic evaluation. Packing was tested as a mitigation, but wasn't effective because unpacking is slow.

Parasitic evaluations (~~200% slowdown)

Any node that evaluates an input can trigger another input connected to the tree being evaluated to "parasitically" evaluate, even if that node is not connected to anything else. This is a bug reported in Github #4175.

Testing for parasitic evaluation of conditional inputs
Parasitic evaluation also appears to affect short-circuiting nodes like Conditional and Multiplexer.

This includes writes and drives and any node that evalutes it's input when pulsed or continuously such as SmoothLerp. It even appears to be triggered by short-circuiting nodes like Conditional and Multiplex.

When a parasitic evaluation is triggered, it evaluates it's input normally as if it were pulsed, or at least this seems highly likely given the impact in testing seems to match that scenario almost perfectly.

It seems that only one extra input is parasitically evaluated per impulse, which is why the performance penalty is typically about 2x slowdown, but it can be much worse depending on what exactly is being parasitically evaluated.

Flux can often be sped up by 2-3x by making sure that each evaluating node has a separate input tree. If a heavy node tree is being parasitically evaluated, the speedup can be dramatic.

Parasitic evaluations are not guaranteed but they will occur in most cases where evaluation trees are not separated per evaluating node. Exceptions to parasitic evaluations seem to be very inconsistent, applying to some inputs and sources but not to others, and not necessarily respecting the type of variable.

Accessing a temporary node output such as DynamicImpulseReceiverWithValue through a relay with multiple wires will often trigger parasitic evaluations and eliminating this relay is usually free.

Sabotages conditional optimizations

Parasitic evaluation is particularly disastrous for tiered conditional evaluations where successively heavier operations are blocked by If statements. If the value trees are not separated, then an unnecessary heavier operation could be evaluated parasitically.

Greatly increases hotloop evaluations

If the hot loop of some flux shares a tree with something outside the hotloop, the hotloop will be parasitically evaluating the connected flux and dramatically reduce performance. This is particularly relevant for FFT operations.

Mitigations

Because the parasitic evaluations do not always occur, testing is always needed to confirm where they are happening. Most of the time they are, but in some cases they won't be and there is currently no reliable explanation for this.

As long as each evaluating node has it's own input tree, then sharing a single variable between nodes in that group will often speed up that group's evaluation slightly by reducing the group size. However if sharing a variable causes two evaluating nodes to read from the same input tree, parasitic evaluation will probably occur.

Note that short-circuiting nodes like Conditional and Multiplexer also may need completely separate input trees to avoid this issue.

Architecturally, ValueSources or RefSources are usually the best solution to parasitic evaluation. This is mainly for two reasons:

  • They allow input trees to be completely separate
  • They only require one write to fill a single variable, which results in the fewest nodes.
  • Change tracking overhead is present, but is usually smaller than the parasitic evaluation penalty. The field is usually localized if it doesn't need to be networked.
  • A great side benefit of this is that flux can become much easier to read and less tangled, which makes it much more maintainable and readable and overall fun to work on.
  • The ValueSource node can be decorated by coloring the parent of the slot it's field is on, providing a way to distinguish variables at a glance.

DataModelStores can be split into ValueSources by sourcing the field on the node's slot, but the field will be difficult to reach in an inspector. For this reason, it's recommended to use ValueTag components to hold variables if they will ever be used outside a node group, so they can be organized in the inspector and titled by changing their slot name.

Local variables may be less likely to trigger parasitic evaluations, as long as they are the exact sharing point between two evaluating nodes. If a relay is inserted after the local variable output, parasitic evaluations will return. Many pulsed node outputs are implemented as local variables so in order to take advantage of this, best practice is to connect shared evaluator trees directly at the local variables only. Often this can be achieved by duplicating any flux they pass through to get to the local variable so they they stay separate right up until it's output.

Simplify arithmetic

If a lot of arithmetic is used, the equations can be looked at with WolframAlpha or other mathematical tools to see if they can be simplified to fewer operations. However the available simplifications heavily depend on the kind of nodes available, for instance ValueLerp can often replace multiple nodes, which few mathematical tools will be able to show. So getting more eyes on your arithmetic can often bring up surprising simplifications. Some nodes which can replace multiple arithmetic nodes include:

  • ValueLerp
    • Clamped
  • ValueLerpUnclamped
  • InverseLerp
    • Converts an arbitrary range to a 0-->1 interval, unclamped
  • Remap
    • Usually not faster unless all it's inputs were actually needed to begin with
  • Remap1101
    • Converts a -1 to +1 interval to a 0-->1 interval, or a bipolar interval to a unit interval

Exploit arithmetic compaction

Pure arithmetic is often faster than branching logic.
Result of optimizing a function meant to replace the PingPong node. Using a branching node like Conditional breaks up the compaction of arithmetic operations. String boxes on right show how many opetations can run in 100us.

Because arithmetic has the fastest per-node operations in flux, and appears to benefit from an internal compaction process, the more you can do in a single arithmetic tree the faster it will be.

Using branching nodes such as Conditional or Multiplexer in the middle of arithmetic will break up this compaction, and these behaviors can often be replicated with arithmetic or via constructions such as ZeroOne --> ValueLerp.

Note that MultiLerp does actually branch and cannot be used for this.

The short-circuiting benefit of branching nodes usually loses badly to arithmetic compaction even when that means allowing unnecessary arithmetic branches to be evaluated.

Switch in arithmetic using ValueFilterInvalid

ValueFilterInvalid used as a switch.
Node group using the ValueFilterInvalid optimization. An invalid result switches the output rather than a conditional or lerp, this was the fastest option in this case. Because the node is branchless, the true and false condition both have about the same speed.

In some functions, and invalid float can be taken advantage of to switch the output without resorting to a conditional or multiplexer which will disrupt arithmetic compaction. This will also be faster than using ZeroOne --> lerp, all else being equal.

In theory, this node also may benefit from NaN propagation to skip floating point operations on NaN values.

Short-circuiting nodes if nothing else

In protoflux, a node is said to be short-circuiting when it avoids evaluating unnecessary inputs.

This includes nodes like Multiplexer, Conditional, ValueLerpMulti, MultiAnd etc. These nodes introduce branching into input trees as opposed to branching in impulse chains using If or ImpulseMultiplexer.

As an example, MultiAnd will stop evaluating at the first input that is false and MultiOr will stop evaluating at the first input that is true. This occurs in top-down order.

When one input is dramatically slower, and most user time will be spent with the faster input being used, these nodes can be taken advantage of to improve performance as an input that isn't selected won't be evaluated.

Short-circuiting is most useful in node trees that don't already benefit from arithmetic compaction which is disrupted by short-circuiting nodes. It should usually be avoided at all costs in the middle of arithmetic, but is often fine at the start or end of arithmetic where it does not interrupt the arithmetic's evaluation.

All else being equal, the CPU time of a conditional is similar to the CPU time of performing the branch explicitly on impulses using the If node. As such, any operations that use a conditional may find advantages in switching to impulse-based branching or vice-versa. This relationship is mirrored for Multiplexer as well in the form of the ImpulseMultiplexer.

This is a very picky optimization so testing the result is important.

Floats have more optimization potential than ints overall

Floats have more available operators to choose from, so when a conversion between them must occur at some point in flux, it is usually better to do more operations on the float side than on the int side. Int operations are comparatively limited and can push you into a corner if you don't need any int-specific operations.

Using ints for certain things can seem fitting but often doesn't have a functional purpose. It is however sometimes possible for floating point errors to ruin integer logic if the flux relies on float values being integers.

Keep input conditioning outside the main loop

If you have FPS as a user setting in your flux, and your flux needs that value in the form of a frametime (1/FPS) instead, you can perform that conversion with a drive and let your main flux use only the driven value.

This way, your hot loop won't be evaluating 1/FPS redundantly each iteration, and the drive will only update the constant when it is changed as a result of change tracking. This can be applied to any value that stays constant throughout the evaluation of a loop.

More complicated input conditioning can be done with FireOnX nodes to update multiple fields if they depend on another field. However beware that crashing loops can form this way and not all of them will be caught by the protoflux validator!

Write constant values to variables before iterating

If you know that a value won't change for the duration of a for loop, write that value to a Store or Local and use that in the for loop to reduce the number of nodes evaluated inside the loop.

This is technically worse than using a drive for that value since it will evaluate more often than change tracking, but often it's only the flux inside the loop that is significant for performance.

Unroll loops into drives

Loop iteration always causes some overhead in performance, so unrolling a loop into a series of separate actions can be the best option. This can completely eliminate parasitic writes if each action has only one drive. The optimizations of for loops still apply such as converting unchanging values to constants that the flux does not have to evaluate.

One notable example is FFT displays where each bar can be a drive. If the individual drives are well optimized, this may be the fastest way to visualize an FFT in Protoflux. This also demonstrates the major downside of this approach, which is the loading hitch caused by having thousands of components.

ForEach vs For+GetAt

A collection can be accessed directly with a ForEach node, or less direcly with a For node with it's index connected to GetAt.

If every value of the collection will be used, then ForEach will be about ~1.27x faster (for a minimal loop) because it has less nodes in the loop. However if you only need say half of the collection values, the for loop will be faster because it will be able to skip the unused entries. RangeLoop can also be used this way.

InverseLerp<Int> in For loops

If a for loop needs to convert it's index to a float (say 0-100 --> 0-1.0), InverseLerp is usually the fastest way to do this because it converts to float for you, saving up to 2 int-->float casts. The output range can be inverted by swapping inputs to the node.

Micro-optimizations

These optimizations usually have little impact in one corner of a node group, but in tight loops (such as FFT operations) they can become important.

They may also add up if many of them can be applied to a node group.

Unpacking faster

This is a very small optimization in most cases and often not worth the harm to readability. Unpack is a relatively slow node in Protoflux. It can be replaced with Dot_Product where one of the input vectors will be zero with a 1 in whichever component you want to unpack. This will be faster than the Unpack node. If you can use Magnitude or MagnitudeSquared, that will be even faster.

Divide by 2 faster

An Average with one input blank is the fastest way to divide by 2 in Protoflux.

RefInput is slightly faster than RefSource

RefInput appears if you drag out an input from say a Slot input. If instead you grab a slot from the inspector and create a Source from your context menu, what you get is a RefSource.

RefSource is technically slower because it must read the data through a global variable.

RefInput on the other hand accesses the data directly.

Aggregate blank node inputs (Ghost nodes)

This is a micro-optimization except in specific cases such as ComposeBits. Each blank node input evaluates a "ghost node" in effect to obtain that input's default value. In a case such as ComposeBits, the number of unused inputs can be high. If these inputs are gathered into a single input node then it will only evaluate once to fill all inputs. If there are many blank zero inputs in a node group they can be treated similarly.

This optimization may not succeed if it causes two input trees to be connected to the constant being used to fill the blank inputs, as it would trigger parasitic evaluations (Github #4175). In this case, each input tree will need a unique input to fill it's blank inputs with.

Case studies in optimizing protoflux

Here are some case studies of actual protoflux groups, how they were tested and what was done to improve their performance.

Entries here should be based on real tests that were reasonably conclusive. Even better if different optimizations can be listed with their performance impact.

Reading pairs of floats vs float2 from collections

Speed tests of different ways of reading float pairs from a collection.

This testing shows that it is faster to read float2s into floats from a float2 collection rather than use a collection of single floats. It also shows that Dot product is faster than unpacking a float2. The test results shown in string boxes are how many iterations can run in 1.67ms.