Distributed Rendering Architecture: The "Pull" Model¶
This document outlines the architectural shift to a Worker-Pull model for the Skewer distributed renderer. This model simplifies network configuration, enhances fault tolerance with Spot Instances, and streamlines autoscaling on GKE.
1. High-Level Workflow¶
A. Job Submission¶
- User (CLI) sends a
SubmitJobrequest to the Coordinator. - Coordinator decomposes the job into granular Tasks:
- Render Tasks: One per layer (e.g., "Background", "Character").
- Composite Tasks: One per frame (merging all layers).
- Coordinator adds these tasks to an internal
JobQueue. - Coordinator exposes a metric (e.g.,
queue_length) for KEDA.
B. Autoscaling (Infrastructure)¶
- KEDA (Kubernetes Event-Driven Autoscaling) polls the Coordinator's metric.
- If
queue_length > 0, KEDA instructs GKE to scale up the Worker Deployment. - New Worker Pods (C++) boot up.
C. Task Execution (The "Pull" Loop)¶
- Worker starts and enters a loop.
- Worker calls
GetTask(worker_id)on the Coordinator. - Coordinator pops a task from the queue and assigns it to the worker.
- If queue is empty: Coordinator returns
Wait. Worker sleeps and retries.
- If queue is empty: Coordinator returns
- Worker processes the task:
- Render Task: Loads scene -> Renders Layer -> Writes EXR to GCS.
- Composite Task: Downloads EXR layers -> Merges -> Writes Image to GCS.
- Worker calls
UpdateTaskStatus(TaskResult)to report success/failure. - Worker loops back to Step 2.
D. Job Completion¶
- When all Render Tasks for a frame are
COMPLETED, the Coordinator creates a Composite Task. - A Worker picks up the Composite Task and produces the final image.
- User polls
GetJobStatusand receives the final GCS URI.
2. Protobuf Restructuring¶
We will consolidate logic into coordinator.proto. The renderer.proto and compositor.proto files essentially become data definition files rather than service definitions, as the Workers are no longer Servers.
A. api/proto/renderer/v1/renderer.proto¶
- Action: KEEP, but remove
service RendererService. - Purpose: Use
RenderLayerRequestas the data structure to describe a "Render Task". - Changes:
- Remove
service RendererService { ... }. - Keep
message RenderLayerRequest(this defines what to render).
- Remove
B. api/proto/compositor/v1/compositor.proto¶
- Action: KEEP, but remove
service CompositorService. - Purpose: Use
CompositeDeepLayersRequestas the data structure to describe a "Composite Task". - Changes:
- Remove
service CompositorService { ... }. - Keep
message CompositeDeepLayersRequest.
- Remove
C. api/proto/coordinator/v1/coordinator.proto¶
- Action: EXPAND. This becomes the single API for the entire system.
- New RPCs:
GetTask,UpdateTaskStatus. - Updated RPCs:
RegisterWorker(simplified),Heartbeat(optional, asGetTaskacts as a heartbeat).
Revised coordinator.proto Definition:¶
syntax = "proto3";
package api.proto.coordinator.v1;
option go_package = "./api/proto/coordinator/v1";
// Import the data structures from the other files
import "renderer/v1/renderer.proto";
import "compositor/v1/compositor.proto";
service CoordinatorService {
// --- User-Facing API ---
rpc SubmitJob(SubmitJobRequest) returns (SubmitJobResponse);
rpc GetJobStatus(GetJobStatusRequest) returns (GetJobStatusResponse);
// --- Worker-Facing API (The "Pull" Mechanism) ---
// 1. Worker connects and asks for work.
// Blocks until work is available or returns "Wait" immediately.
rpc GetTask(GetTaskRequest) returns (GetTaskResponse);
// 2. Worker reports the result of a task (Success/Failure + Output URIs)
rpc UpdateTaskStatus(UpdateTaskStatusRequest) returns (UpdateTaskStatusResponse);
// 3. (Optional) Workers can still register to provide metadata (CPU count, etc.)
rpc RegisterWorker(RegisterWorkerRequest) returns (RegisterWorkerResponse);
}
// ... SubmitJobRequest, GetJobStatusRequest remain the same ...
message GetTaskRequest {
string worker_id = 1;
// Capability tags? e.g. ["gpu", "high-mem"]
repeated string tags = 2;
}
message GetTaskResponse {
enum TaskType {
TASK_TYPE_WAIT = 0; // No work, sleep and retry
TASK_TYPE_RENDER = 1; // Do a render
TASK_TYPE_COMPOSITE = 2; // Do a composite
}
TaskType task_type = 1;
string task_id = 2;
// Only one of these will be set, depending on task_type
api.proto.renderer.v1.RenderLayerRequest render_task = 3;
api.proto.compositor.v1.CompositeDeepLayersRequest composite_task = 4;
}
message UpdateTaskStatusRequest {
string task_id = 1;
string worker_id = 2;
bool success = 3;
string error_message = 4;
// If success, where is the output?
string output_uri = 5;
// Performance metrics
int64 execution_time_ms = 6;
}
message UpdateTaskStatusResponse {
// Acknowledgment
bool success = 1;
}
3. Implementation Plan¶
Phase 1: Proto Updates & Go Skeleton¶
- Modify Protos: Apply the changes above. Regenerate Go and C++ bindings.
- Update
main.go: Ensure it only registersCoordinatorService. - Update
scheduler.go: Implement theTaskQueue(just asliceorchannelfor now).
Phase 2: C++ Worker Loop¶
- Refactor Worker:
- Remove
grpc::Server. - Add
grpc::Clientconnecting to Coordinator. - Implement
while(true)loop:GetTask()if (RENDER)-> call existing Render logic.UpdateTaskStatus()
- Remove
Phase 3: Cloud & KEDA¶
- Dockerize: Build
skewer-workerimage. - Deploy: Helm chart with
Deployment(replicas: 0). - Autoscale: Add KEDA
ScaledObjectpointing to Coordinator's metric endpoint (e.g.,/metricsif using Prometheus, or a custom API).