20 KiB
Frontend: state tracker, texture upload, shaders (formerly ARCHITECTURE §5–§7)
Design details. The design highlights and an index of all sections are in
../ARCHITECTURE.md. Section numbers follow the originalARCHITECTURE.md;ARCHITECTURE.md §Nin code comments resolves by number here.
5. Frontend state tracker (MG_Impl/Pipe/Tracker)
5.1 Pushing happens at validate time before a verb, not in GL setters
Per-setter pushing would turn Blaze3D's redundant glEnable/glDisable in every batch into interface calls, strictly slower than today; the correct form is gallium's st_validate_state. It lands as the single MGPipeValidateForVerb(MGPipeVerb) (nine validate classes, FillPoints.def), exactly before every call through the tables and after every early return. Only the resource ops that are already dispatched at GL call time today (the seven hooks of BufferBackendOps) push at call time.
5.2 Dirty bits: zero new bookkeeping for value classes, five aggregate generations for object classes
- Value-class groups (render state, pixel pack, patch, attrib defaults, shader bindings …) reuse the existing version counters directly as the shutter.
- Object-class groups (vertex buffers, framebuffer, sampler views / samplers / images, const / shader buffers, SO targets) reduce the shutter to a single
Uint64comparison through five aggregate generations — without aggregation there is no way to answer "did any bound texture move". - Completeness is guaranteed by
scripts/gen_pipe_dirty_surface.py(every mutator → the generation that must be bumped,--check+--self-testin CI). A shutter that cannot see its own subject is a trap we have hit three times: a new field must come with a "record field → setter → shutter" line, and mixing it into an existing generation is preferred (a new counter would bloat the pull object, which G1 does not allow).
5.2.1 Answers of the dirty-surface mapping (DirtySurface.def)
DirtySurface.def states, for every frontend mutator observable by the backend, "who publishes this change". A missing publication does not error, it only silently renders stale state, so the list is mechanically enumerated by scripts/gen_pipe_dirty_surface.py: it scans MG_Impl/GLImpl and MG_State/GLState for pGLContext-> calls matching MUTATOR_PREFIXES and for MGP_NOTE_MUTATION sites (the latter carry a field name, and the line is written on the enclosing function); --check cross-checks the line set in both directions.
The answers list only publishers that fire on every path that makes a modification (an early return with an unchanged value does not count as a modification); a bit that fires only on some paths, if listed separately, would make the table-constructed shutter under-trigger, and under-triggering is the dangerous direction.
NEW_*: a dirty bit inTracker.h, published at the next verb;kImmediate: straight to the backend from the same function body;kReverseChannel: the backend writes back to the frontend;kNoBackendRead: there is no backend read site.kExplicitDestroy: when the last reference is released, the client'sMGPipeEmit*DestroyAndFreein turn does the wire delete (when the class has a delete line), the backend notification, and frees the slot;kUnpublishedDestroy: program pipelines and shaders have no wire handle, so there is nothing to delete.kPulledEveryVerb: no bit movement; the field is in the may-read table of its verb class, and the residual fill copies it at every verb;kPulledPartialShutter|NEW_*: likewise backed by the pull, with the attached bit firing only on some modification paths.
Bit answers are derived by the script: the RenderState family is compared both ways with RenderState.cpp (BumpVersions() moves two counters, a bare ++m_version moves only NEW_RENDER_STATE); for the remaining bits, the members / fields the shutter reads are intersected with those the mutator transitively writes, which can only detect the one wrong answer "this bit's shutter never moves on any path", and cannot prove that every path moves. Functions whose write root cannot be located are tainted, and answers that depend on them are UNDECIDED and must be listed in MGP_DIRTY_SURFACE_UNDECIDED_LIST; listing one that is already decidable also turns red. Prose answers are not derived (except the bits attached to kPulledPartialShutter); the scanner matches function bodies by text, mutators inside lambdas are attributed to the enclosing function, and a change published through a helper is read as a deferred publication; the semantic proof is still MOBILEGL_PIPE_VERIFY.
5.2.2 Record field → setter → shutter table
Every field of a record triggered by a dirty bit in Tracker.h may only change through a setter that moves that bit's shutter input; otherwise it must be emitted unconditionally at the setter (the resource_* family, set_texture_params). DirtySurface.def only maps mutators to bits and cannot see that a derived field depends on a mutator of another family, so the table below is maintained by hand; when a record gains a field, add a row here.
| Bit | Record and fields | Setters that change the fields | Shutter inputs |
|---|---|---|---|
| 6 | create/bind_shader_state, set_draw/dispatch_program: Cso, StageMask, GlobalUboSize, artifact blob ref, the two bound handles |
glUseProgram, glLinkProgram, glBindProgramPipeline, glUseProgramStages |
lifetime id × link version; under a pipeline, stageLinks (pipeline name × each stage's lifetime id and link version) |
| 7 | program bindings: image units, block bindings, uniform write set | glUniform1i on opaque uniforms, glUniformBlockBinding, glShaderStorageBlockBinding, any glUniform* |
the four per-program counters; under a pipeline, stageLinks is mixed in as well |
| 8 | set_global_constants: ShaderCso, Version, default uniform block mirror |
glUniform* on the default block, glUseProgram |
lifetime id × UBO content version; under a pipeline, stageState |
| 11 | set_framebuffer_state: Fbo, Color[8]/Depth/Stencil/ReadSurface (Res, Kind, InternalFormat, TextureTarget, Layered, Level, Layer, UploadTarget), DrawBuffers[8], Width/Height/Layers/Samples/FixedSampleLocations, IsDefault, Complete, Target |
glFramebufferTexture*, glFramebufferRenderbuffer, glDrawBuffer(s), glReadBuffer, glFramebufferParameteri, glBindFramebuffer; storage redefinition of an attached texture or renderbuffer (format, texture target, size, sample count, completeness are inlined at emission, so PipePublishDescriptor bumps the attachment aggregate) |
attachment aggregate × draw / read binding slot version |
| 12 | set_sampler_views: per unit {View, Texture} |
glBindTexture, glActiveTexture, texture or sampler parameters, uploads defining a level, appearance of the default texture; the program in use (glUseProgram, relink, glUniform1i of a sampler uniform) |
content × params × bind generation × opaqueUnits |
| 13 | bind_sampler_states: per unit, the sampler CSO handle |
glBindSampler, glDeleteSamplers, glSamplerParameter*, glTexParameter* |
params × sampling resolution × bind generation |
| 14 | set_shader_images: per unit within the program's image-unit window, {Res, InternalFormat, Layer, Level, Layered, Access} |
glBindImageTexture, texture content / params, glUniform1i of an image uniform; the program in use (glUseProgram, relink) |
content × params × bind generation × programImages |
Bits 11–14 also fire when the share group's twin aggregate clock changes (the setter is invoked through a sibling context); when the maximum touched texture unit changes, 12 and 13 also fire.
5.3 Render state: one whole blob crosses the wire, identity takes only the pipeline subset (D-B1)
create_render_state(cso, pipelineSubsetChunks) // pipeline subset only
bind_render_state(cso, version, pipelineVersion) // 12 B in steady state
set_dynamic_state(dynamicChunks, version) // only the changed dynamic chunks
RenderStateParameters(1168 B) crosses as one whole block: Espryt does a three-segment memcmp, and field order is load-bearing; splitting it into several CSOs would require hand-maintaining a ~150-field partition with no tripwire.- Identity takes only the pipeline subset, otherwise
glViewport/glScissorwould mint a new CSO every time and flush the server's pipeline memo. The partition is written in one place only: the chunk table inMGPipeRenderStateSpans(7 pipeline chunks 396 B + 8 dynamic chunks 772 B,static_assertcompleteness), and G7 asserts "the subset hash changes ⟺m_pipelineStateVersionchanges". - Espryt's
SyncRenderStateis untouched; Magma's pipeline memo key iscso.slot.
5.4 Validation invariants, merging and suppressors
The rule (D-B3): all set_* / bind_* of a verb complete before that verb; the server lazily specializes shaders and pipelines at the verb from the pushed state. Apart from "a resource is created before it is bound", there is no ordering requirement among set_*. Merging: whole blocks beat per-field; the high-water mark is directly the count argument; only the sets the program resolved are sent; the set hash suppressor debounces on the client side (previously ~175 lines in the backend).
5.5 Sampler views are resolved on the client
Which texture a shader sees depends on the sampler uniform type, mipmap completeness and the undefined default texture; resolution stays on the client (with a memo), and gallium's "one view per slot" is the resolved form. Two backend-specific post-processing steps stay on the server: Espryt's raw-depth-fetch sampler substitution and Magma's feedback-loop detection.
5.6 Lifetime, share groups, composite programs
resource_createis sent when the frontend object is constructed, storage is lazily defined byresource_respecify, andresource_destroyis sent at destruction; the death is forwarded to every emitter (freeing only the slot previously caused a UAF). A view is destroyed before its storage owner, an FBO attachment pins its texture, and a buffer texture pins its buffer.- The program pipeline composite (
CompositeResolver) is fully composed on the frontend and pushes a single handle; it is released exactly once (the resolver swaps it out, or~ProgramObject, D-H7); the memo is keyed by(ContextId, pipeline GL name).
5.6.1 Handle, release and memo key of the program pipeline composite
- The composite is composed on the frontend by
GLContext::GetProgramForDraw()and cached in its ownProgramPipelineObject; the program emitter reserves the high segment ofShaderCsoby the composite's own lifetime id (§2.1) and gives it a handle, sendingcreate_shader_statelike an ordinary program, and the server does not know it is a composite.MGPipeSlotAllocator::Allocaterejects that segment andAllocateCompositeis the only entry; the reserved segment is used instead of a handle flag because a flag would have to be carried and masked by everyShaderCsoconsumer on both sides, whereas the reserved segment makes "an ordinary program cannot get a composite slot" guaranteed by the allocator. - A slot has two release paths:
MGPipeCompositeResolver::Observereleases the old composite when the signature changes, and the composite's own~ProgramObject. Both go throughMGPipeEmitShaderCsoDestroyAndFree, and the later one is a no-op (MGPipeSlotAllocator::Freerejects a slot not live in that generation and does not advance the generation either). Relying on the destructor alone is not enough: once the frontend holds an extra reference, the destructor will not come first when the signature changes. - The signature is
ComputeDrawProgramSignature()(each graphics stage's lifetime id and link version), excludingGetBackendStateVersion(): the latter also changes onglUniform1iof a sampler uniform, which would make the composite recompile on every draw. Bit 6's shutter inTracker.hreads the same set of values. - The memo key is
(GLContext::GetTextureContextId(), pipeline GL name): the resolver is a process singleton, while pipeline names are allocated per context; keying by name alone would hit the same-named entry of another context after make-current and release a composite that is still alive, rebuilding the program on every switch; the context address is also wrong, because a destroyed and recreated context can land at the old address, whereas this id is never reused. - When a context is destroyed, its composite is released through
~ProgramObject, and leftover entries can never be found again;Reset()at make-current discards them without releasing, so the table length is bounded only by the entries whose composite slots are still live.
5.7 Ownership of emulation
Rule: transformations the driver cannot express are lowered in the tracker; transformations forced by the hardware / driver are lowered in the driver.
| Falls to the client (pure CPU transformations that read frontend bytes) | Falls to the server |
|---|---|
client vertex / index arrays (bytes cross the wire, never pointers), maximum index scan, *IndirectCount count resolution, CopyImage shadow mirror, XFB CPU primitive count, compressed texture / unpack normalization |
primitive-restart rewriting, multi-draw tiering and flattening (read from the index host mirror, §10.3), viewport-array replay, fp64 vertex narrowing, image-bindable storage widening, mipmap generation (the client allocates level storage) |
Stale index discipline is a per-site table: the reconcile before a client-side scan must reproduce monolith's set verbatim (maximum index scan = SyncPersistentMappedRange() + SyncGpuWrites(); *IndirectCount only the former).
6. Texture subdata and dirty ownership
- Texture uploads are done by Espryt at sync time over the accumulated region (Mali prices by job count; ~100 rects against one union box measured +6 ms/frame), so sending a record per
glTexSubImagewould reproduce this cliff. - So the client accumulates in its own rect model and sends one
ResourceSubDataat the next validate / flush point, carrying both the union box and the region list, and the server picks the upload shape (the decision stays on the side that pays the GPU cost). The stride is carried explicitly, no longer relying on pointer comparison. - Dirty ownership is reversed: the client maintains the emission cursor and clears its own flags after emission; clearing dirty waits for the applier's acceptance (a rejected level stays in the dirty table); the server's own re-dirtying is the server's business.
TextureUploadShapeScenariorecords the upload shape as golden (a gate since P3b/P4b D2; Mali frame-time deltas have no device here, recorded on Adreno).
6.1 Server-side pending upload set
- The client clears its dirty flag for a level as soon as the applier accepts that level's
ResourceSubData; the backend's upload loop has early-return arms (Espryt: incomplete texture, multisample target only refreshes), so accepted shapes are accumulated by the applier per(uploadTarget, level)inMGPipeResourceRecord::PendingUploads, consumed and cleared only where the backend actually uploads, and no number of bails loses texels. - Accumulation reuses the client's rect model: the union box is taken as a union, rect lists are concatenated; if either side has only a box, or the list would exceed
kMGPipeMaxPendingUploadRegions, that entry degrades to a box-only entry (still covering every texel, just transferring extra gaps). - respecify discards only the pending uploads on the storage it replaces (the boxes are in that storage's coordinate system; preserving them across a shrink would upload out of bounds):
- an empty level = a whole-resource redefinition (
glBufferData/glBufferStorage,glTexStorage*, texture views), clearing everything; - a named level = one
glTexImage*D, dropping only that(uploadTarget, level), regardless of whether the descriptor changed. Clearing everything would lose texels of other levels that were already accepted but not yet uploaded:glTexImage2D(0)→ draw (texture incomplete, backend bails) →glTexImage2D(1); - a metadata-only change (
RespecifyRedefinesNoStorage: the descriptor re-sent when the stickyBindMask/ImageBindableHintchanges) drops nothing.
- an empty level = a whole-resource redefinition (
- A buffer has no pending uploads and never counts as metadata-only either.
6.1.1 The region list is the exact dirty footprint
The region list of a texture resource_subdata is the exact dirty footprint of that level since it last became clean: disjoint boxes that cover every written texel and not one more (MGPipeTextureEmitter::EmitOneLevel). Reason: once a server-side image may contain GPU writes, an upload beyond the application's write range would overwrite them. Magma always treats the list as the texels to upload; Espryt does the same when the driver texture may contain GPU writes, and otherwise still chooses the upload shape itself between the box and the rects (the Mali job count is still decided by the server).
RegionCount == 0only means a whole-level write; every other write carries boxes (even a single one), so the applier's accumulation never relaxes an exact contribution into a union box because one side is "box only".- Exception: when the footprint exceeds the
kMGPipeMaxPendingUploadRegionsboxes one record can carry, it falls back to the storage's rect list (coverage only, logged once); when that list is in turn withheld (the rects fill 3/4 of the union box), 0 is an inexact partial box. Readers that read "box only" as "the client owns every texel in the box" (the coverage-box rule ofAdoptDriverLevelIntoShadow) are inexact only in this case.
6.1.2 Texture view uploads are recorded on the storage owner
glTextureView shares texels with the source texture, so the client records every upload made through a view or through the owner's name on the storage owner: TextureObjectView converts the dirty mark into owner coordinates (ToOwnerLevel / ToOwnerUploadTarget / ToOwnerRegionOffset) and forwards it to the owner, the drain list in TextureEmit.h is keyed by (storage owner, upload target, level), and every texture resource_subdata points at the owner handle; the applier therefore advances only the owner record's Serial and PendingUploads.
- Espryt's per-texture clean gate
BackendTextureObject::IsDrawSyncCleanByRecordreads the owner record pointed to byDesc.ViewOf(PipeTextureStorageRecordForRecord) for the two storage clauses, while the three parameter clauses still read the view's own record: a view has its own texture parameters and built-in sampler. This is the same rule as the frontend'sTextureObjectView::GetContentVersion()forwarding to the owner. - If the storage clauses read the view's own record, uploads through the owner's name would never become visible on the view after it is first sampled. The gate is
TextureViewAliasScenario.TheOwnerUploadDrainRunsAgainAfterADrawBoundary, run under monolith and all three transport arms.
7. Shader state = SPIR-V + reflection archive
CreateShaderStatesends per-stage SPIR-V + a reflection archive across the wire, not source: glslang entirely on the client, SPIRV-Cross entirely on the server, a file-level split; no compile pool on the server. The archive serializer isProgramArtifactsCodec, and monolith calls it only in verify builds (D-H3).- The server lazily specializes at the verb from the pushed state (D-B2: draw FBO clamp mask, fragColor broadcast count, storage-block binding signature, image formats, patch parameters, etc.) — exactly what the two backends did before.
- link / compile failure needs no synchronous return:
GL_LINK_STATUSis answered by the client from theProgramObject, and backend failures surface as ≥ERROR lines in the server log (control-planeLogLine, lossless,MG_Remote/Transport/LogForward.h). - Internal shaders (color blit, depth mip, multisample resolve) are baked into SPIR-V signed into the tree (
Wire*Spirv.h), and the freshness gateMOBILEGL_BAKED_INTERNAL_SHADERS(MG_Test/SelfTest/BakedInternalShadersTest.cpp) reruns in-tree glslang and compares byte for byte (P7).