diff --git a/rfc/210.0001-parallel-gles-linking.md b/rfc/210.0001-parallel-gles-linking.md new file mode 100644 index 0000000..d87eb56 --- /dev/null +++ b/rfc/210.0001-parallel-gles-linking.md @@ -0,0 +1,291 @@ +--- +type: rfc +rfc: '210.0001' +title: Parallel OpenGLES Linking in Impeller +description: Outlines how Impeller will implement parallel shader compilation with the OpenGLES backend. +status: draft +created: 2026-10-09T00:00:00Z +updated: 2026-10-09T23:07:13.777951Z +tags: + - 210-graphics +authors: + - https://github.com/gaaclarke + - https://github.com/walley892 +--- + +# RFC 210.0001: Parallel OpenGLES Linking in Impeller + +## Description + +This design doc lays out a refactoring of the pipeline compilation process on +Impeller OpenGLES that allows supporting backends to parallelize shader +compilation which will increase the likelihood that a shader is immediately +availble for use in the first frames of rendering. + +## Background + +On backends like Vulkan and Metal we are able to precompile shaders at +compile-time. For the OpenGLES backend we need to compile them at runtime. The +API provides a few functions to help make that happen: + +- [glCompileShader](https://registry.khronos.org/OpenGL-Refpages/es3.0/html/glCompileShader.xhtml) +- [glLinkProgram](https://registry.khronos.org/OpenGL-Refpages/gl4/html/glLinkProgram.xhtml) +- [GL_LINK_STATUS](https://registry.khronos.org/OpenGL-Refpages/gl4/html/glLinkProgram.xhtml) + +The OpenGLES standard doesn't specify exactly when the linking needs to be done, +it just has to be realized before it's actually used. This allows some drivers +to allow parallel compilation jobs to happen at the same time. Currently +Impeller is not taking advantage of this. It is instead serially issuing +compilation jobs and querying the `GL_LINK_STATUS` which forces synchronization +on the compilation job. + +In Impeller we work with Pipelines, but from the OpenGLES perspective there are +programs and there are pipelines. A program represents a compiled bundle of +shaders, for example a vertex shader and a fragment shader. A pipeline +represents a variant of those compiled shaders, for example with specialization +constants set. Programs can be shared across pipelines and they have their own +cache. + +ANGLE supports parallel compilation, which is what Impeller uses on Windows. On +Android it doesn't seem to be as common. There isn't a direct check for support +but we can infer from the presence of the `KHR_parallel_shader_compile` +extension that some devices do support it. + +## Technical Details + +The PipelineLibraryGLES design as it stands today is not well suited to handle +parallel compilation jobs. For one thing, the logic for the PipelineLibraryGLES +class is split among 3 different classes: PipelineLibraryGLES, +PipelineCompileQueue, PipelineCompileQueueGLES. This makes it difficult for us +to reason about and make the necessary changes. The following refactors will +first need to be redone: + +- Delete PipelineCompileQueue - It's artificially making the Vulkan compile + queue and the OpenGLES queue behaves similarly for no reason. There is one + place in the code that grabs the queue from the library and talks to it + directly to eagerly perform a job. That request should come through the + library directly. +- Create a class to represent pending programs - This will allow us to maintain + the concept of a compiling program when it no longer is a synchronous + operation. + +### Class diagram + +Here's what things will look like after the refactoring. + +```mermaid +classDiagram + +class CompileJob { + -descriptor + +Start()* + +Finish()* +} + +class BasicTaskRunner { + +PostTask(task)* +} + +class ReactorGLES { + +React() +} + +class PipelineCompileQueueGLES { + -supports_parallel_compile + -max_inflight_compile_jobs + -atomic~int~ outstanding_jobs +} + +PipelineCompileQueueGLES *-- BasicTaskRunner +PipelineCompileQueueGLES *-- "*" CompileJob +PipelineCompileQueueGLES *-- "* compiling" CompileJob + +class PipelineLibrary { + +GetPipeline(descriptor, bool async) PipelineFuture + +GetPipeline(descriptor, bool async, bool threadsafe)* PipelineFuture +} + +class PipelineLibraryGLES { + -ProgramMap programs + -PipelineMap pipelines + -CreatePipeline(library, descriptor, vert, frag, bool threadsafe) PipelineGLES + -LinkProgram(reactor, pipeline, vert, frag) bool +} + +PipelineLibrary <|-- PipelineLibraryGLES +PipelineLibraryGLES *-- ReactorGLES +PipelineLibraryGLES *-- "*" PipelineFuture +PipelineLibraryGLES *-- PipelineCompileQueueGLES +PipelineLibraryGLES *-- "*" PendingProgram + +class PendingProgram { + +Compile() + +Wait() +} + +class PipelineFuture { + +descriptor + +future + +Get() Pipeline +} +``` + +### Sequence diagram + +This shows how managing jobs will happen with the PipelineCompileQueueGLES and +the io thread. + +```mermaid +sequenceDiagram +participant raster as (raster) +participant io as (io) +participant library as :PipelineLibraryGLES +participant queue as :PipelineCompileQueueGLES +participant joba as a:Job +participant jobb as b:Job +participant jobc as c:Job +participant jobd as d:Job + +raster->>+library: GetPipelineAsync + library->>+queue: PostJobForDescriptor + queue->>+io: ScheduleRun + io-->>-queue: return + queue-->>-library: return +library-->>-raster: return + +io->>+queue: Run + queue->>+queue: FinishOldestJob + Note right of queue: Happens because max pending links is hit + queue->>+jobd: Wait + Note right of jobd: glGetStatus(LINK_STATUS) + jobd-->>-queue: return + queue-->>-queue: return + queue->>+io: ScheduleRun + io-->>-queue: return +queue-->>-io: return + +io->>+queue: Run + queue->>+joba: Start + joba-->>-queue: return + queue->>+io: ScheduleRun + io-->>-queue: return +queue-->>-io: return + +raster->>+library: GetPipelineSync + library->>+queue: PerformJobEagerly + Note right of queue: Job is started but not completed + queue->>+jobb: Wait + +io->>+jobb: Wait() +jobb-->>-io: return + + jobb-->>-queue: return + queue-->>-library: return +library-->>-raster: return + +raster->>+library: GetPipelineSync + library->>+queue: PerformJobEagerly + Note right of queue: Job is not started + queue->>+jobc: ExecSync + jobc-->>-queue: return + queue-->>-library: return +library-->>-raster: return + +raster->>+library: Delete + library->>+queue: Delete + queue->>+queue: FinishPendingJobs +io->>+joba: Wait +joba-->>-io: return + queue-->>-queue: return + queue-->>-library: return +library-->>-raster: return +``` + +### Pseudocode + +Here is the logic that will happen inside of the queue. It works recursively on +the io thread, doing one operation at a time and allows out of order +synchronization when an eager request happens from the raster thread. + +```dart +class PipelineQueue { + // Raster thread + void StartJob(Job job) { + _inactive_jobs.Add(job) + ScheduleRun() + } + + + // io thread + void Run() { + if (_eager_request){ + Job job = _active_jobs.Find(_eager_request) + _eager_request = null + if (job) { + job.Synchronize() + } + _condition_variable.Signal() + ScheduleRun() + } else if (_active_jobs.count < LIMIT) { + Job job = _inactive_jobs.Pop() + job.Start() + _active_jobs.Add(job) + ScheduleRun() + } else { + Job job = _active_jobs.Pop() + if (job) { + job.Synchronize() + ScheduleRun() + } + } + } + + + // Raster thread + void RunEager(Desc desc) { + Job job = _inactive_jobs.Remove(desc) + if (job) { + job.Start() + job.Synchronize() + } else { + _eager_request = desc + ScheduleRun() + _condition_variable.Wait() + } + } +} +``` + +The LIMIT value will be informed by the number of cores avaiable to the machine. +The driver itself is managing its own thread pools and the LIMIT just controls +how much we make visible to the io thread in order to limit the latency when +performing a compilation eagerly. + +## Testing + +We already have the benefit of many high level integration tests. Since this +change is happening under the covers and at a critical point in rendering that +100% of renders will hit that will provide a lot of coverage. + +The new logic, parallel execution, will be exercised in unit tests on +PipelineCompileQueueGLES. The following cases should be be covered: + +- Destruction with outstanding pending jobs and active jobs +- Eager execution swaps the priority of synchronization of active jobs +- Hammering the queue enforces a limited number of jobs + +## Alternatives considered + +### Use the `KHR_parallel_shader_compile` extension + +There is an extension that directly deals with parallel compilation of shaders. +It opens up an API that allows one to query the status of a compile job without +blocking. Previous attempts at fixing this problem used that API and guarded +parallel execution on the presence of that extension. + +That API is a polling API, there is no callback when compilation is complete. No +good heuristic could be thought up that does polling efficently without some +tradeoffs. It is also possible that a driver supports parallel compilation but +does not support the extension. By implementing the code with the assumption of +parallel compilation, the code is cleaner and we capture more potential benefits +if the drivers support it.