Rolling Event Queue - Optimization
Posted: Sat Feb 10, 2018 9:09 pm
It is my understanding that, with the way the game works now, a few very fast assemblers use less CPU time than a lot of slow assemblers for the same output, because all working assemblers have to be updated on every tick, no matter whether anything interesting happens on that tick or not. This is why most people use heavily beaconed configurations for megabases. But, I think, it doesn't have to be this way.
Suppose you have a length-300 circular buffer of lists of Event objects. That represents the next 5 seconds. The 0th list is the current tick, the 1st list is the next tick, and so on. On each tick, you process all the Events in the 0th list, and step the circular buffer forward by 1 tick.
Simplest case, recipes shorter than 5 seconds: On the current tick, an assembler's output/input buffer contents allow it to start working on a recipe that takes 2 seconds to complete. Record, in the assembler entity's state object, that it {is animating, starting from $frame_number, on $current_tickstamp}. Append an Event to the 120th list in the circular buffer that says the recipe finishes on that tick. Then put the assembler to sleep.
Recipes longer than 5 seconds: On the current tick, an assembler's output/input buffer contents allow it to start working on a recipe that takes 8 seconds to complete. Instead of making an Event to finish the recipe, make an Event to increment the recipe progress by 5/8, and append it to the list at the very end of the buffer. When that tick gets evaluated, you'll create another Event to finish the recipe.
Logistic/construction robots: On the current tick, a robot is dispatched for a job. Calculate how many ticks in the future it will reach its destination (distance/speed), and how many ticks until it runs out of power ( charge/(speed*energy_per_distance + energy_per_time) ), and depending on which will happen first, either create an Event for the robot to collect/deliver its cargo, or an Event for the robot to divert to a roboport for charging. If you want, you could actually determine which roboport that would be and divert the robot immediately. That way, robots on long journeys would travel from roboport to roboport until they were within battery-range of their destination, instead of doing that sawtooth thing they do now.
Inserters: Whenever an inserter starts moving, create an Event on the tick when it will reach the end of its swing.
But what about electricity? Create two numeric circular buffers, per-electric-network, for the next and previous 300 ticks. These are the demand and satisfaction buffers. Whenever you insert an Event into the rolling queue, increment every element of the demand buffer between now and the tick the Event comes due by the power draw of whatever entity it is. (I notice that requires doing per-tick work again, alas. But it's straight vector math.) On each tick, store the fraction of demand satisfied in the satisfaction buffer (should be 1 under non-brownout conditions). When you process an Event for an entity that slows down if insufficiently powered, average the values in the satisfaction buffer since the Event was created, and if the result is less than 1, advance the entity's progress of that fraction of the expected progress and schedule a new Event.
What this all achieves is that instead of updating a working assembler's state on every tick, you update it once when it starts a recipe, and once when it finishes, or once every 300 ticks if the recipe takes longer than that. (300 ticks is just a guess, actually selecting the buffer length would require benchmarking.) Unlike a traditional event-driven simulation with a priority queue, which is O(nlog(n)) IIRC, the rolling event queue makes the assumption that lots of events happen on every timestep, and is O(n*ceil(T/l)), where T is how long in advance events are scheduled and l is the length of the buffer.
Suppose you have a length-300 circular buffer of lists of Event objects. That represents the next 5 seconds. The 0th list is the current tick, the 1st list is the next tick, and so on. On each tick, you process all the Events in the 0th list, and step the circular buffer forward by 1 tick.
Simplest case, recipes shorter than 5 seconds: On the current tick, an assembler's output/input buffer contents allow it to start working on a recipe that takes 2 seconds to complete. Record, in the assembler entity's state object, that it {is animating, starting from $frame_number, on $current_tickstamp}. Append an Event to the 120th list in the circular buffer that says the recipe finishes on that tick. Then put the assembler to sleep.
Recipes longer than 5 seconds: On the current tick, an assembler's output/input buffer contents allow it to start working on a recipe that takes 8 seconds to complete. Instead of making an Event to finish the recipe, make an Event to increment the recipe progress by 5/8, and append it to the list at the very end of the buffer. When that tick gets evaluated, you'll create another Event to finish the recipe.
Logistic/construction robots: On the current tick, a robot is dispatched for a job. Calculate how many ticks in the future it will reach its destination (distance/speed), and how many ticks until it runs out of power ( charge/(speed*energy_per_distance + energy_per_time) ), and depending on which will happen first, either create an Event for the robot to collect/deliver its cargo, or an Event for the robot to divert to a roboport for charging. If you want, you could actually determine which roboport that would be and divert the robot immediately. That way, robots on long journeys would travel from roboport to roboport until they were within battery-range of their destination, instead of doing that sawtooth thing they do now.
Inserters: Whenever an inserter starts moving, create an Event on the tick when it will reach the end of its swing.
But what about electricity? Create two numeric circular buffers, per-electric-network, for the next and previous 300 ticks. These are the demand and satisfaction buffers. Whenever you insert an Event into the rolling queue, increment every element of the demand buffer between now and the tick the Event comes due by the power draw of whatever entity it is. (I notice that requires doing per-tick work again, alas. But it's straight vector math.) On each tick, store the fraction of demand satisfied in the satisfaction buffer (should be 1 under non-brownout conditions). When you process an Event for an entity that slows down if insufficiently powered, average the values in the satisfaction buffer since the Event was created, and if the result is less than 1, advance the entity's progress of that fraction of the expected progress and schedule a new Event.
What this all achieves is that instead of updating a working assembler's state on every tick, you update it once when it starts a recipe, and once when it finishes, or once every 300 ticks if the recipe takes longer than that. (300 ticks is just a guess, actually selecting the buffer length would require benchmarking.) Unlike a traditional event-driven simulation with a priority queue, which is O(nlog(n)) IIRC, the rolling event queue makes the assumption that lots of events happen on every timestep, and is O(n*ceil(T/l)), where T is how long in advance events are scheduled and l is the length of the buffer.