Gliner/Gpu/GlinerGpuCommandListProbe.cs
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Threading.Tasks;
using Sandbox;
namespace GlinerPoc.Gpu;
/// <summary>
/// Phase 8A.1 — render-context compute dispatch probe.
///
/// P8A proved the DIRECT out-of-context ComputeShader.Dispatch path broken on
/// 26.09.22 (project shaders silently no-op "bound pipeline does not have a
/// compute shader"; library shaders crash the editor). This probe tests
/// compute execution INSIDE a real render context, using the public API:
///
/// 1. SceneCustomObject.RenderSceneObject() override — the official
/// in-render hook (exactly the pattern of Facepunch's gpu-voxels
/// library: a scene object whose render callback drains an action
/// queue on the render thread). Inside it, Graphics.Attributes is a
/// live render-context attribute set and ComputeShader.Dispatch runs
/// "async" per its own docs.
/// 2. Rendering.CommandList.DispatchCompute recorded on a command list
/// attached to the camera via CameraComponent.AddCommandList (the
/// decompiled entry executes computeShader.DispatchWithAttributes(
/// Graphics.Attributes, ...) during the camera render).
/// 3. GpuBuffer.GetDataAsync issued from inside the render hook — the
/// place P8A proved a render context exists.
///
/// Canary policy (P8A.1 §6): outputs poisoned to 0xdeadbeef before dispatch;
/// success = every element equals the expected value, all elements checked,
/// expected-vs-actual mismatches reported. "No exception" is never success.
/// </summary>
[Title( "GLiNER GPU CommandList Probe" )]
[Category( "GLiNER" )]
public sealed class GlinerGpuCommandListProbe : Component
{
[Property]
public bool RunOnStart { get; set; } = true;
[Property]
public GlinerPoc.Packaging.GlinerModelResource ModelResource { get; set; }
private int _passed;
private int _failed;
internal readonly ConcurrentQueue<Action> RenderQueue = new();
private GlinerClRenderHook _hook;
private static float Lcg( ref uint state )
{
state = state * 1664525u + 1013904223u;
return ((state >> 8) & 0xFFFF) / 65535f - 0.5f;
}
private static float[] LcgArray( int count, uint seed )
{
uint s = seed;
var a = new float[count];
for ( int i = 0; i < count; i++ )
{
a[i] = Lcg( ref s );
}
return a;
}
protected override void OnStart()
{
if ( RunOnStart )
{
_ = RunAsync();
}
}
protected override void OnDestroy()
{
_hook?.Delete();
_hook = null;
}
private async Task RunAsync()
{
var sw = Stopwatch.StartNew();
Log.Info( "[GLI:CL] render-context compute probe start (SceneCustomObject.RenderSceneObject + Rendering.CommandList)" );
try
{
var camera = FindMainCamera();
if ( camera is null )
{
throw new InvalidOperationException( "[GLI:ERROR] No CameraComponent found in the scene." );
}
Log.Info( $"[GLI:CL] camera resolved: {camera.GameObject.Name}" );
_hook = new GlinerClRenderHook( Scene.SceneWorld, this );
await Task.Delay( 200 ); // let the hook render at least once
// 1: render-hook dispatch with the project (main-Assets) shader
bool projectOk = await GateHookVec( "Shaders/gliner/gliner_cl_vec_add.shader", "hook_vec_project" );
// 2: single controlled differential with the library shader
// (registers CS modules; DIRECT out-of-context dispatch
// crashed the editor in P8A)
bool libraryOk = false;
if ( !projectOk )
{
libraryOk = await GateHookVec( "Shaders/gliner/gliner_vec_add.shader", "hook_vec_library" );
}
if ( !projectOk && !libraryOk )
{
Log.Info( "[GLI:CL] RENDER-CONTEXT PATH ALSO FAILS — documenting BLOCK (P8A.1 §9)" );
}
string goodPath = projectOk ? "Shaders/gliner/gliner_cl_vec_add.shader"
: (libraryOk ? "Shaders/gliner/gliner_vec_add.shader" : null);
// 3: camera-attached CommandList.DispatchCompute variant (uses the
// bindings the hook places into Graphics.Attributes)
if ( goodPath is not null )
{
await GateCameraCommandListVec( camera, goodPath );
await GateHookAsyncReadback( goodPath );
await GateGemmSynthetic();
await GateGemmRealLayer0Q();
}
Log.Info( $"[GLI:CL] probe complete passed={_passed} failed={_failed} total_ms={sw.ElapsedMilliseconds}" );
Log.Info( _failed == 0
? "[GLI:CL] ALL PASS — render-context GPU compute executes"
: $"[GLI:CL] FAILURES PRESENT ({_failed})" );
}
catch ( Exception error )
{
Log.Error( $"[GLI:CL] harness failure: {error}" );
}
finally
{
_hook?.Delete();
_hook = null;
}
}
private CameraComponent FindMainCamera()
{
CameraComponent fallback = null;
foreach ( var go in Scene.GetAllObjects( false ) )
{
var cam = go.GetComponent<CameraComponent>();
if ( cam is null || !cam.Enabled )
{
continue;
}
if ( cam.IsMainCamera )
{
return cam;
}
fallback ??= cam;
}
return fallback;
}
// ---- canary: out[i] = i*0.25f + constant, bit-exact -------------------------
private async Task<bool> GateHookVec( string shaderPath, string gateName )
{
const int N = 64;
var input = new float[N];
for ( int i = 0; i < N; i++ )
{
input[i] = i * 0.25f;
}
var expected = new float[N];
for ( int i = 0; i < N; i++ )
{
expected[i] = input[i] + 1.0f;
}
try
{
var shader = new ComputeShader( shaderPath );
using var inBuf = new GpuBuffer<float>( N );
using var outBuf = new GpuBuffer<float>( N );
inBuf.SetData( input );
outBuf.Clear( 0xdeadbeefu );
int dispatchCount = 0;
// executed INSIDE RenderSceneObject on the render thread:
RenderQueue.Enqueue( () =>
{
var attrs = Graphics.Attributes;
attrs.Set( "InputValues", inBuf );
attrs.Set( "OutputValues", outBuf );
attrs.Set( "ValueCount", N );
attrs.Set( "AddConstant", 1f );
shader.Dispatch( N, 1, 1 ); // in-graphics-context: queued async per docs
dispatchCount++;
} );
var sw = Stopwatch.StartNew();
await Task.Delay( 500 ); // frames render; hook drains the queue in-context
var result = new float[N];
outBuf.GetData( result, 0, N );
sw.Stop();
await Task.Delay( 100 );
int mismatches = 0;
int stillPoison = 0;
var firstBad = new System.Text.StringBuilder();
for ( int i = 0; i < N; i++ )
{
if ( BitConverter.SingleToInt32Bits( result[i] ) != BitConverter.SingleToInt32Bits( expected[i] ) )
{
mismatches++;
if ( BitConverter.SingleToInt32Bits( result[i] ) == unchecked((int)0xdeadbeef) )
{
stillPoison++;
}
if ( firstBad.Length < 100 )
{
_ = firstBad.Append( $"[{i}] exp={expected[i]:0.###e-00} act={result[i]:0.###e-00} " );
}
}
}
bool ok = mismatches == 0 && dispatchCount >= 1;
Log.Info( $"[GLI:CL] {gateName} ('{shaderPath}'): dispatchesInContext={dispatchCount} " +
$"expected-vs-actual mismatches={mismatches}/{N} stillPoison={stillPoison} | firstBad: {firstBad} | wallMs={sw.Elapsed.TotalMilliseconds:N0}" );
Gate( gateName, ok,
ok
? $"ALL {N} elements bit-exact — KERNEL EXECUTED inside the render context"
: (stillPoison == mismatches
? "output untouched (poison) — in-context dispatch still no-oped"
: "output changed but wrong values"),
"" );
return ok;
}
catch ( Exception e )
{
Log.Error( $"[GLI:CL] {gateName} ('{shaderPath}') threw {e.GetType().Name}: {e.Message}" );
Gate( gateName, false, $"exception {e.GetType().Name}: {e.Message}", "" );
return false;
}
}
// ---- camera-attached CommandList.DispatchCompute ------------------------------
private async Task GateCameraCommandListVec( CameraComponent camera, string shaderPath )
{
const int N = 64;
var input = new float[N];
for ( int i = 0; i < N; i++ )
{
input[i] = i * 0.25f;
}
try
{
var shader = new ComputeShader( shaderPath );
using var inBuf = new GpuBuffer<float>( N );
using var outBuf = new GpuBuffer<float>( N );
inBuf.SetData( input );
outBuf.Clear( 0xdeadbeefu );
// CommandList entries cannot bind structured buffers (no public
// attribute command; the decompiled DispatchCompute binds
// Graphics.Attributes), so the render hook supplies the bindings
// each frame and the camera stage executes the recorded dispatch.
int bindFrames = 0;
void Bind()
{
var attrs = Graphics.Attributes;
attrs.Set( "InputValues", inBuf );
attrs.Set( "OutputValues", outBuf );
attrs.Set( "ValueCount", N );
attrs.Set( "AddConstant", 3f );
bindFrames++;
}
var cl = new Sandbox.Rendering.CommandList( "gliner_cl_camera_vec" );
cl.Clear( outBuf, 0xdeadbeefu );
cl.DispatchCompute( shader, N, 1, 1 );
// AfterDepthPrepass (1000) executes BEFORE scene objects render, so
// the hook's Graphics.Attributes bindings would not exist yet;
// AfterOpaque (2000) runs after the scene pass that drains the hook.
camera.AddCommandList( cl, Sandbox.Rendering.Stage.AfterOpaque, 0 );
var sw = Stopwatch.StartNew();
var result = new float[N];
int mismatches = -1;
int stillPoison = -1;
for ( int frame = 0; frame < 12; frame++ )
{
RenderQueue.Enqueue( Bind );
await Task.Delay( 42 );
outBuf.GetData( result, 0, N );
mismatches = 0;
stillPoison = 0;
for ( int i = 0; i < N; i++ )
{
float e = input[i] + 3f;
if ( BitConverter.SingleToInt32Bits( result[i] ) != BitConverter.SingleToInt32Bits( e ) )
{
mismatches++;
if ( BitConverter.SingleToInt32Bits( result[i] ) == unchecked((int)0xdeadbeef) )
{
stillPoison++;
}
}
}
if ( mismatches == 0 )
{
break;
}
}
sw.Stop();
camera.RemoveCommandList( cl );
await Task.Delay( 100 );
Log.Info( $"[GLI:CL] camera command list: bindFrames={bindFrames} mismatches={mismatches}/{N} stillPoison={stillPoison} wallMs={sw.Elapsed.TotalMilliseconds:N0} " +
$"sampleActual[0..3]={result[0]:0.###e-00},{result[1]:0.###e-00},{result[2]:0.###e-00},{result[3]:0.###e-00} " +
$"expected[0..3]={input[0] + 3f:0.###e-00},{input[1] + 3f:0.###e-00},{input[2] + 3f:0.###e-00},{input[3] + 3f:0.###e-00}" );
Gate( "cl_camera_commandlist", mismatches == 0,
mismatches == 0
? $"ALL {N} elements bit-exact via CameraComponent.AddCommandList + CommandList.DispatchCompute"
: $"not verified through the camera command list (mismatches={mismatches}, stillPoison={stillPoison}) — bindings may not reach the stage; the render-hook path remains the proven one",
"" );
}
catch ( Exception e )
{
Log.Error( $"[GLI:CL] camera command list gate threw {e.GetType().Name}: {e.Message}" );
Gate( "cl_camera_commandlist", false, $"exception {e.GetType().Name}: {e.Message}", "" );
}
}
// ---- async readback issued inside the render hook ------------------------------
private async Task GateHookAsyncReadback( string shaderPath )
{
const int N = 64;
var input = new float[N];
for ( int i = 0; i < N; i++ )
{
input[i] = i * 0.25f;
}
try
{
var shader = new ComputeShader( shaderPath );
using var inBuf = new GpuBuffer<float>( N );
using var outBuf = new GpuBuffer<float>( N );
inBuf.SetData( input );
var result = new float[N];
bool completed = false;
long completedAtMs = 0;
var swAll = Stopwatch.StartNew();
RenderQueue.Enqueue( () =>
{
var attrs = Graphics.Attributes;
attrs.Set( "InputValues", inBuf );
attrs.Set( "OutputValues", outBuf );
attrs.Set( "ValueCount", N );
attrs.Set( "AddConstant", 2f );
shader.Dispatch( N, 1, 1 );
// issued AFTER the dispatch in the same in-context callback:
// P8A proved GetDataAsync needs exactly this context
outBuf.GetDataAsync( ( ReadOnlySpan<float> data ) =>
{
for ( int i = 0; i < N; i++ )
{
result[i] = data[i];
}
completedAtMs = swAll.ElapsedMilliseconds;
completed = true;
} );
} );
int waits = 0;
while ( !completed && waits < 120 )
{
await Task.Delay( 16 );
waits++;
}
await Task.Delay( 100 );
int mismatches = 0;
for ( int i = 0; i < N; i++ )
{
if ( BitConverter.SingleToInt32Bits( result[i] ) != BitConverter.SingleToInt32Bits( input[i] + 2f ) )
{
mismatches++;
}
}
Gate( "hook_async_readback", completed && mismatches == 0,
$"completed={completed} bitExact={(mismatches == 0)} completionAfterMs={completedAtMs} waits={waits} (GetDataAsync inside RenderSceneObject — legal render context)", "" );
}
catch ( Exception e )
{
Log.Error( $"[GLI:CL] async readback gate threw {e.GetType().Name}: {e.Message}" );
Gate( "hook_async_readback", false, $"exception {e.GetType().Name}: {e.Message}", "" );
}
}
// ---- GEMM through the render hook ----------------------------------------------
private async Task<(float[] Gpu, double WallMs)> RunHookGemm( string shaderPath,
float[] x, float[] w, float[] bias, int rows, int inDim, int outDim )
{
var shader = new ComputeShader( shaderPath );
using var xBuf = new GpuBuffer<float>( x.Length );
using var wBuf = new GpuBuffer<float>( w.Length );
using var bBuf = new GpuBuffer<float>( bias is null ? 1 : bias.Length );
using var yBuf = new GpuBuffer<float>( rows * outDim );
xBuf.SetData( x );
wBuf.SetData( w );
if ( bias is not null )
{
bBuf.SetData( bias );
}
yBuf.Clear( 0xdeadbeefu );
RenderQueue.Enqueue( () =>
{
var attrs = Graphics.Attributes;
attrs.Set( "X", xBuf );
attrs.Set( "W", wBuf );
attrs.Set( "Bias", bBuf );
attrs.Set( "Y", yBuf );
attrs.Set( "RowCount", rows );
attrs.Set( "InDim", inDim );
attrs.Set( "OutDim", outDim );
attrs.Set( "HasBias", bias is not null ? 1 : 0 );
shader.Dispatch( rows, outDim, 1 );
} );
var sw = Stopwatch.StartNew();
await Task.Delay( 400 );
var y = new float[rows * outDim];
yBuf.GetData( y, 0, y.Length );
sw.Stop();
await Task.Delay( 100 );
return (y, sw.Elapsed.TotalMilliseconds);
}
private async Task GateGemmSynthetic()
{
const string shaderPath = "Shaders/gliner/gliner_cl_gemm.shader";
bool all = true;
all &= await HookGemmCase( shaderPath, 4, 3, 2, true, 7001, "cl_gemm_syn_bias" );
all &= await HookGemmCase( shaderPath, 4, 3, 2, false, 7002, "cl_gemm_syn_nobias" );
all &= await HookGemmCase( shaderPath, 5, 7, 3, true, 7003, "cl_gemm_syn_odd" );
Gate( "cl_gemm_synthetic", all, "3 synthetic shapes (incl. odd/group-remainder dims, ±bias) via render-context dispatch vs CPU oracle", "" );
}
private async Task<bool> HookGemmCase( string shaderPath,
int rows, int inDim, int outDim, bool withBias, uint seed, string name )
{
float[] x = LcgArray( rows * inDim, seed );
float[] w = LcgArray( outDim * inDim, seed + 1 );
float[] b = LcgArray( outDim, seed + 2 );
float[] cpu = await Task.RunInThreadAsync( () =>
GlinerPoc.Neural.GlinerMath.Linear( x, rows, w, withBias ? b : null, inDim, outDim ) );
var run = await RunHookGemm( shaderPath, x, w, withBias ? b : null, rows, inDim, outDim );
var m = GlinerPoc.Neural.GlinerMath.Compare( run.Gpu, cpu );
Gate( name, m.Pass,
$"[{rows},{inDim}]x[{inDim},{outDim}] bias={withBias} maxAbs={m.MaxAbs:0.###e-00} meanAbs={m.MeanAbs:0.###e-00} " +
$"maxRel={m.MaxRel:0.###e-00} worst={m.WorstIndex} wallMs={run.WallMs:N0}", "" );
return m.Pass;
}
private async Task GateGemmRealLayer0Q()
{
if ( ModelResource is null )
{
Gate( "cl_gemm_real_layer0_query", false, "no ModelResource assigned", "" );
return;
}
GlinerPoc.Neural.GlinerModelWeights weights =
await Task.RunInThreadAsync( () => GlinerPoc.Neural.GlinerModelWeights.FromResource( ModelResource ) );
int seq = GlinerPoc.Neural.NeuralP4FixturesV2.EmbeddingCaseSeqLen; // 40
float[] embOut = DecodeB64( GlinerPoc.Neural.NeuralP4FixturesV2.EmbeddingStageOutput );
float[] wq = weights.GetTensor( "encoder.encoder.layer.0.attention.self.query_proj.weight" );
float[] bq = weights.GetTensor( "encoder.encoder.layer.0.attention.self.query_proj.bias" );
var cpuRun = await Task.RunInThreadAsync( () =>
{
var sw = Stopwatch.StartNew();
float[] y = GlinerPoc.Neural.GlinerMath.Linear( embOut, seq, wq, bq, 384, 384 );
return (y, sw.Elapsed.TotalMilliseconds);
} );
var run = await RunHookGemm( "Shaders/gliner/gliner_cl_gemm.shader", embOut, wq, bq, seq, 384, 384 );
var mGpuVsCpu = GlinerPoc.Neural.GlinerMath.Compare( run.Gpu, cpuRun.y );
float[] pythonRef = DecodeB64( GlinerPoc.Neural.NeuralP4FixturesV2.Layer0QueryProjOutput );
var mGpuVsPython = GlinerPoc.Neural.GlinerMath.Compare( run.Gpu, pythonRef );
Gate( "cl_gemm_real_layer0_query", mGpuVsCpu.Pass && mGpuVsPython.Pass,
$"packaged query_proj W[384,384]+b, X=real embedding output [{seq},384] | " +
$"GPUvsCPU maxAbs={mGpuVsCpu.MaxAbs:0.###e-00} meanAbs={mGpuVsCpu.MeanAbs:0.###e-00} | " +
$"GPUvsPython maxAbs={mGpuVsPython.MaxAbs:0.###e-00} | cpuMs={cpuRun.Item2:N2} attachToResultMs={run.WallMs:N0}", "" );
}
private static float[] DecodeB64( string b64 )
{
byte[] bytes = Convert.FromBase64String( b64 );
return GlinerPoc.Neural.GlinerMath.DecodeF32( bytes, 0, bytes.Length / 4 );
}
private void Gate( string name, bool pass, string detail, string extra )
{
if ( pass )
{
_passed++;
Log.Info( $"[GLI:CL] PASS {name} {detail} {extra}" );
}
else
{
_failed++;
Log.Error( $"[GLI:CL] FAIL {name} {detail} {extra}" );
}
}
}
/// <summary>
/// Official in-render hook (pattern copied from Facepunch's gpu-voxels
/// SceneDummyObject): a SceneCustomObject whose render callback drains the
/// probe's action queue on the render thread, inside a live graphics context.
/// It renders nothing itself.
/// </summary>
internal sealed class GlinerClRenderHook : SceneCustomObject
{
private readonly GlinerGpuCommandListProbe _probe;
public GlinerClRenderHook( SceneWorld sceneWorld, GlinerGpuCommandListProbe probe )
: base( sceneWorld )
{
_probe = probe;
// always-visible bounds so the object is never frustum-culled
Bounds = new BBox( Vector3.One * -100000f, Vector3.One * 100000f );
RenderingEnabled = true;
}
public override void RenderSceneObject()
{
while ( _probe.RenderQueue.TryDequeue( out var action ) )
{
action();
}
}
}