Skip to content

Commit

Permalink
implement frame rate independent task scheduling with effort-based ta…
Browse files Browse the repository at this point in the history
…sk duration estimation
  • Loading branch information
douira committed Oct 11, 2024
1 parent 055543d commit d574dab
Show file tree
Hide file tree
Showing 18 changed files with 196 additions and 66 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ public class RenderSection {
// Pending Update State
@Nullable
private CancellationToken taskCancellationToken = null;
private long lastMeshingTaskEffort = 1;

@Nullable
private ChunkUpdateType pendingUpdateType;
Expand Down Expand Up @@ -170,6 +171,14 @@ private void clearRenderState() {
this.visibilityData = VisibilityEncoding.NULL;
}

public void setLastMeshingTaskEffort(long effort) {
this.lastMeshingTaskEffort = effort;
}

public long getLastMeshingTaskEffort() {
return this.lastMeshingTaskEffort;
}

/**
* Returns the chunk section position which this render refers to in the level.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import net.caffeinemc.mods.sodium.client.render.chunk.compile.executor.ChunkBuilder;
import net.caffeinemc.mods.sodium.client.render.chunk.compile.executor.ChunkJobCollector;
import net.caffeinemc.mods.sodium.client.render.chunk.compile.executor.ChunkJobResult;
import net.caffeinemc.mods.sodium.client.render.chunk.compile.executor.JobEffortEstimator;
import net.caffeinemc.mods.sodium.client.render.chunk.compile.tasks.ChunkBuilderMeshingTask;
import net.caffeinemc.mods.sodium.client.render.chunk.compile.tasks.ChunkBuilderSortingTask;
import net.caffeinemc.mods.sodium.client.render.chunk.compile.tasks.ChunkBuilderTask;
Expand Down Expand Up @@ -53,7 +54,9 @@
import org.joml.Vector3dc;

import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class RenderSectionManager {
private static final float NEARBY_REBUILD_DISTANCE = Mth.square(16.0f);
Expand All @@ -67,6 +70,8 @@ public class RenderSectionManager {
private final Long2ReferenceMap<RenderSection> sectionByPosition = new Long2ReferenceOpenHashMap<>();

private final ConcurrentLinkedDeque<ChunkJobResult<? extends BuilderTaskOutput>> buildResults = new ConcurrentLinkedDeque<>();
private ChunkJobCollector lastBlockingCollector;
private final JobEffortEstimator jobEffortEstimator = new JobEffortEstimator();

private final ChunkRenderer chunkRenderer;

Expand All @@ -80,8 +85,6 @@ public class RenderSectionManager {

private final SortTriggering sortTriggering;

private ChunkJobCollector lastBlockingCollector;

@NotNull
private SortedRenderLists renderLists;

Expand All @@ -90,7 +93,10 @@ public class RenderSectionManager {

private int frame;
private int lastGraphDirtyFrame;
private long lastFrameDuration = -1;
private long averageFrameDuration = -1;
private long lastFrameAtTime = System.nanoTime();
private static final float AVERAGE_FRAME_DURATION_FACTOR = 0.05f;

private boolean needsGraphUpdate = true;
private boolean needsRenderListUpdate = true;
Expand Down Expand Up @@ -138,8 +144,18 @@ public void updateCameraState(Vector3dc cameraPosition, Camera camera) {
// TODO idea: increase and decrease chunk builder thread budget based on if the upload buffer was filled if the entire budget was used up. if the fallback way of uploading buffers is used, just doing 3 * the budget actually slows down frames while things are getting uploaded. For this it should limit how much (or how often?) things are uploaded. In the case of the mapped upload, just making sure we don't exceed its size is probably enough.

public void updateRenderLists(Camera camera, Viewport viewport, boolean spectator, boolean updateImmediately) {
var now = System.nanoTime();
this.lastFrameDuration = now - this.lastFrameAtTime;
this.lastFrameAtTime = now;
if (this.averageFrameDuration == -1) {
this.averageFrameDuration = this.lastFrameDuration;
} else {
this.averageFrameDuration = (long)(this.lastFrameDuration * AVERAGE_FRAME_DURATION_FACTOR) +
(long)(this.averageFrameDuration * (1 - AVERAGE_FRAME_DURATION_FACTOR));
}
this.averageFrameDuration = Mth.clamp(this.averageFrameDuration, 1_000_100, 100_000_000);

this.frame += 1;
this.lastFrameAtTime = System.nanoTime();
this.needsRenderListUpdate |= this.cameraChanged;

// do sync bfs based on update immediately (flawless frames) or if the camera moved too much
Expand Down Expand Up @@ -553,6 +569,8 @@ private boolean processChunkBuildResults(ArrayList<BuilderTaskOutput> results) {
TranslucentData oldData = result.render.getTranslucentData();
if (result instanceof ChunkBuildOutput chunkBuildOutput) {
this.updateSectionInfo(result.render, chunkBuildOutput.info);
result.render.setLastMeshingTaskEffort(chunkBuildOutput.getEffort());

touchedSectionInfo = true;

if (chunkBuildOutput.translucentData != null) {
Expand Down Expand Up @@ -613,12 +631,19 @@ private static List<BuilderTaskOutput> filterChunkBuildResults(ArrayList<Builder

private ArrayList<BuilderTaskOutput> collectChunkBuildResults() {
ArrayList<BuilderTaskOutput> results = new ArrayList<>();

ChunkJobResult<? extends BuilderTaskOutput> result;

while ((result = this.buildResults.poll()) != null) {
results.add(result.unwrap());
var jobEffort = result.getJobEffort();
if (jobEffort != null) {
this.jobEffortEstimator.addJobEffort(jobEffort);
}
}

this.jobEffortEstimator.flushNewData();

return results;
}

Expand All @@ -642,9 +667,8 @@ public void updateChunks(boolean updateImmediately) {
thisFrameBlockingCollector.awaitCompletion(this.builder);
} else {
var nextFrameBlockingCollector = new ChunkJobCollector(this.buildResults::add);
var deferredCollector = new ChunkJobCollector(
this.builder.getTotalRemainingBudget(),
this.buildResults::add);
var remainingDuration = this.builder.getTotalRemainingDuration(this.averageFrameDuration);
var deferredCollector = new ChunkJobCollector(remainingDuration, this.buildResults::add);

// if zero frame delay is allowed, submit important sorts with the current frame blocking collector.
// otherwise submit with the collector that the next frame is blocking on.
Expand Down Expand Up @@ -794,11 +818,17 @@ private void submitSectionTasks(ChunkJobCollector collector, DeferMode deferMode
return null;
}

return new ChunkBuilderMeshingTask(render, frame, this.cameraPosition, context);
var task = new ChunkBuilderMeshingTask(render, frame, this.cameraPosition, context);
task.estimateDurationWith(this.jobEffortEstimator);
return task;
}

public ChunkBuilderSortingTask createSortTask(RenderSection render, int frame) {
return ChunkBuilderSortingTask.createTask(render, frame, this.cameraPosition);
var task = ChunkBuilderSortingTask.createTask(render, frame, this.cameraPosition);
if (task != null) {
task.estimateDurationWith(this.jobEffortEstimator);
}
return task;
}

public void processGFNIMovement(CameraMovement movement) {
Expand Down Expand Up @@ -973,8 +1003,8 @@ public Collection<String> getDebugStrings() {
list.add(String.format("Geometry Pool: %d/%d MiB (%d buffers)", MathUtil.toMib(deviceUsed), MathUtil.toMib(deviceAllocated), count));
list.add(String.format("Transfer Queue: %s", this.regions.getStagingBuffer().toString()));

list.add(String.format("Chunk Builder: Permits=%02d (E %03d) | Busy=%02d | Total=%02d",
this.builder.getScheduledJobCount(), this.builder.getScheduledEffort(), this.builder.getBusyThreadCount(), this.builder.getTotalThreadCount())
list.add(String.format("Chunk Builder: Permits=%02d (%04d%%) | Busy=%02d | Total=%02d",
this.builder.getScheduledJobCount(), (int)(this.builder.getBusyFraction(this.lastFrameDuration) * 100), this.builder.getBusyThreadCount(), this.builder.getTotalThreadCount())
);

list.add(String.format("Chunk Queues: U=%02d", this.buildResults.size()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ public BuiltSectionMeshParts getMesh(TerrainRenderPass pass) {
return this.meshes.get(pass);
}

public long getEffort() {
long size = 0;
for (var data : this.meshes.values()) {
size += data.getVertexData().getLength();
}
return 1 + (size >> 8); // make sure the number isn't huge
}

@Override
public void destroy() {
super.destroy();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,11 @@ public class ChunkBuilder {
* min((mesh task upload size) / (sort task upload size), (mesh task time) /
* (sort task time)).
*/
public static final int HIGH_EFFORT = 10;
public static final int LOW_EFFORT = 1;
public static final int EFFORT_UNIT = HIGH_EFFORT + LOW_EFFORT;
public static final int EFFORT_PER_THREAD_PER_FRAME = EFFORT_UNIT;
private static final float HIGH_EFFORT_BUDGET_FACTOR = (float)HIGH_EFFORT / EFFORT_UNIT;

static final Logger LOGGER = LogManager.getLogger("ChunkBuilder");

private final ChunkJobQueue queue = new ChunkJobQueue();

private final List<Thread> threads = new ArrayList<>();

private final AtomicInteger busyThreadCount = new AtomicInteger();

private final ChunkBuildContext localContext;

public ChunkBuilder(ClientLevel level, ChunkVertexType vertexType) {
Expand All @@ -67,16 +58,8 @@ public ChunkBuilder(ClientLevel level, ChunkVertexType vertexType) {
* Returns the remaining effort for tasks which should be scheduled this frame. If an attempt is made to
* spawn more tasks than the budget allows, it will block until resources become available.
*/
public int getTotalRemainingBudget() {
return Math.max(0, this.threads.size() * EFFORT_PER_THREAD_PER_FRAME - this.queue.getEffortSum());
}

public int getHighEffortSchedulingBudget() {
return Math.max(HIGH_EFFORT, (int) (this.getTotalRemainingBudget() * HIGH_EFFORT_BUDGET_FACTOR));
}

public int getLowEffortSchedulingBudget() {
return Math.max(LOW_EFFORT, this.getTotalRemainingBudget() - this.getHighEffortSchedulingBudget());
public long getTotalRemainingDuration(long durationPerThread) {
return Math.max(0, this.threads.size() * durationPerThread - this.queue.getJobDurationSum());
}

/**
Expand Down Expand Up @@ -170,8 +153,8 @@ public int getScheduledJobCount() {
return this.queue.size();
}

public int getScheduledEffort() {
return this.queue.getEffortSum();
public float getBusyFraction(long frameDuration) {
return (float) this.queue.getJobDurationSum() / (frameDuration * this.threads.size());
}

public int getBusyThreadCount() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ public interface ChunkJob extends CancellationToken {

boolean isStarted();

int getEffort();
long getEstimatedDuration();
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ public class ChunkJobCollector {
private final Consumer<ChunkJobResult<? extends BuilderTaskOutput>> collector;
private final List<ChunkJob> submitted = new ArrayList<>();

private int budget;
private long duration;

public ChunkJobCollector(Consumer<ChunkJobResult<? extends BuilderTaskOutput>> collector) {
this.budget = Integer.MAX_VALUE;
this.duration = Long.MAX_VALUE;
this.collector = collector;
}

public ChunkJobCollector(int budget, Consumer<ChunkJobResult<? extends BuilderTaskOutput>> collector) {
this.budget = budget;
public ChunkJobCollector(long duration, Consumer<ChunkJobResult<? extends BuilderTaskOutput>> collector) {
this.duration = duration;
this.collector = collector;
}

Expand All @@ -47,10 +47,10 @@ public void awaitCompletion(ChunkBuilder builder) {

public void addSubmittedJob(ChunkJob job) {
this.submitted.add(job);
this.budget -= job.getEffort();
this.duration -= job.getEstimatedDuration();
}

public boolean hasBudgetRemaining() {
return this.budget > 0;
return this.duration > 0;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@
import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;

class ChunkJobQueue {
private final ConcurrentLinkedDeque<ChunkJob> jobs = new ConcurrentLinkedDeque<>();

private final AtomicInteger jobEffortSum = new AtomicInteger();
private final AtomicLong jobDurationSum = new AtomicLong();

private final Semaphore semaphore = new Semaphore(0);

Expand All @@ -30,7 +30,7 @@ public void add(ChunkJob job, boolean important) {
} else {
this.jobs.addLast(job);
}
this.jobEffortSum.addAndGet(job.getEffort());
this.jobDurationSum.addAndGet(job.getEstimatedDuration());

this.semaphore.release(1);
}
Expand All @@ -45,7 +45,7 @@ public ChunkJob waitForNextJob() throws InterruptedException {

var job = this.getNextTask();
if (job != null) {
this.jobEffortSum.addAndGet(-job.getEffort());
this.jobDurationSum.addAndGet(-job.getEstimatedDuration());
}
return job;
}
Expand All @@ -58,7 +58,7 @@ public boolean stealJob(ChunkJob job) {
var success = this.jobs.remove(job);

if (success) {
this.jobEffortSum.addAndGet(-job.getEffort());
this.jobDurationSum.addAndGet(-job.getEstimatedDuration());
} else {
// If we didn't manage to actually steal the task, then we need to release the permit which we did steal
this.semaphore.release(1);
Expand Down Expand Up @@ -89,7 +89,7 @@ public Collection<ChunkJob> shutdown() {
// force the worker threads to wake up and exit
this.semaphore.release(Runtime.getRuntime().availableProcessors());

this.jobEffortSum.set(0);
this.jobDurationSum.set(0);

return list;
}
Expand All @@ -98,8 +98,8 @@ public int size() {
return this.semaphore.availablePermits();
}

public int getEffortSum() {
return this.jobEffortSum.get();
public long getJobDurationSum() {
return this.jobDurationSum.get();
}

public boolean isEmpty() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,24 @@
public class ChunkJobResult<OUTPUT> {
private final OUTPUT output;
private final Throwable throwable;
private final JobEffort jobEffort;

private ChunkJobResult(OUTPUT output, Throwable throwable) {
private ChunkJobResult(OUTPUT output, Throwable throwable, JobEffort jobEffort) {
this.output = output;
this.throwable = throwable;
this.jobEffort = jobEffort;
}

public static <OUTPUT> ChunkJobResult<OUTPUT> exceptionally(Throwable throwable) {
return new ChunkJobResult<>(null, throwable);
return new ChunkJobResult<>(null, throwable, null);
}

public static <OUTPUT> ChunkJobResult<OUTPUT> successfully(OUTPUT output, JobEffort jobEffort) {
return new ChunkJobResult<>(output, null, jobEffort);
}

public static <OUTPUT> ChunkJobResult<OUTPUT> successfully(OUTPUT output) {
return new ChunkJobResult<>(output, null);
return new ChunkJobResult<>(output, null, null);
}

public OUTPUT unwrap() {
Expand All @@ -29,4 +35,8 @@ public OUTPUT unwrap() {

return this.output;
}

public JobEffort getJobEffort() {
return this.jobEffort;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,15 @@ public void execute(ChunkBuildContext context) {
ChunkJobResult<OUTPUT> result;

try {
var start = System.nanoTime();
var output = this.task.execute(context, this);

// Task was cancelled while executing
if (output == null) {
return;
}

result = ChunkJobResult.successfully(output);
result = ChunkJobResult.successfully(output, JobEffort.untilNowWithEffort(this.task.getClass(), start, this.task.getEffort()));
} catch (Throwable throwable) {
result = ChunkJobResult.exceptionally(throwable);
ChunkBuilder.LOGGER.error("Chunk build failed", throwable);
Expand All @@ -68,7 +69,7 @@ public boolean isStarted() {
}

@Override
public int getEffort() {
return this.task.getEffort();
public long getEstimatedDuration() {
return this.task.getEstimatedDuration();
}
}
Loading

0 comments on commit d574dab

Please sign in to comment.