Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/guide-source-anchors.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"sim/compute_backend.v#L785": "pub struct ParticleMotionSoa {",
"sim/compute_backend.v#L834": "pub fn cpu_step_particle_motion(mut particles ParticleMotionSoa) bool {",
"sim/compute_backend.v#L86": "pub fn shot_bullet_collision_candidates(shots []Shot, bullets []Bullet) []ShotCollisionCandidate {",
"sim/compute_backend_test.v#L267": "fn test_requested_opencl_backend_falls_back_to_identical_cpu_results() {",
"sim/compute_backend_test.v#L271": "fn test_requested_opencl_backend_falls_back_to_identical_cpu_results() {",
"sim/course.v#L161": "pub fn generate_course(seed u32) CourseProfile {",
"sim/course.v#L288": "pub fn (course &CourseProfile) slice_at(position f32) CourseSlice {",
"sim/course.v#L299": "pub fn course_side(angle f32, slice CourseSlice) int {",
Expand Down
26 changes: 25 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,11 @@ jobs:
# call the optional OpenCL path. Keep those declarations for this suite.
run: python3 scripts/check_compiler_diagnostics.py -- v -no-skip-unused -d opencl_compute -cc gcc test sim
- name: Compile OpenCL backend with TinyCC
run: python3 scripts/check_compiler_diagnostics.py -- v -d opencl_compute -cc tcc -o torus_trooper_opencl_tcc . && ./torus_trooper_opencl_tcc --headless --ticks 120 --compute opencl | grep -F 'compute_active=opencl'
run: |
python3 scripts/check_compiler_diagnostics.py -- v -d opencl_compute -cc tcc -o torus_trooper_opencl_tcc .
./torus_trooper_opencl_tcc --headless --ticks 120 --compute opencl | grep -F 'compute_active=opencl'
python3 scripts/check_compiler_diagnostics.py -- v -d opencl_compute -d opencl_dynamic -cc tcc -o torus_trooper_opencl_dynamic_tcc .
python3 scripts/test_opencl_runtime.py ./torus_trooper_opencl_dynamic_tcc --require-opencl
- name: Verify deterministic OpenCL headless run
run: xvfb-run -a env TT_OPENCL_SMOKE=1 TT_REPLAY_UI_SMOKE=1 ./scripts/check.sh
- name: Package Linux candidate
Expand Down Expand Up @@ -349,6 +353,26 @@ jobs:
$env:GLFW_LIB = Join-Path $GlfwRoot 'lib'
$Executable = Join-Path $env:RUNNER_TEMP 'torus_trooper.exe'
.\scripts\build_windows.ps1 -OutputPath $Executable -Verify
- name: Verify optional OpenCL startup on Windows
shell: pwsh
run: |
$HeadersRef = (Get-Content modules/antono2/opencl/HEADERS_COMMIT -Raw).Trim()
if ($HeadersRef -notmatch '^[0-9a-f]{40}$') { throw 'Invalid OpenCL header revision' }
$HeadersRoot = Join-Path $env:RUNNER_TEMP 'opencl-headers'
git init $HeadersRoot
git -C $HeadersRoot fetch --depth 1 https://github.com/KhronosGroup/OpenCL-Headers.git $HeadersRef
if ($LASTEXITCODE -ne 0) { throw 'Cannot fetch pinned OpenCL headers' }
git -C $HeadersRoot checkout --detach FETCH_HEAD
if ($LASTEXITCODE -ne 0) { throw 'Cannot check out pinned OpenCL headers' }
Copy-Item (Join-Path $HeadersRoot 'CL/*') modules/antono2/opencl/include/CL -Force
$GlfwRoot = Join-Path $env:VCPKG_INSTALLATION_ROOT 'installed/x64-mingw-static'
$env:GLFW_INCLUDE = Join-Path $GlfwRoot 'include'
$env:GLFW_LIB = Join-Path $GlfwRoot 'lib'
$DynamicExecutable = Join-Path $env:RUNNER_TEMP 'torus-opencl-dynamic.exe'
python scripts/check_compiler_diagnostics.py -- v -subsystem console -d opencl_compute -d opencl_dynamic -cc clang -cflags -Wno-incompatible-pointer-types -o $DynamicExecutable .
if ($LASTEXITCODE -ne 0) { throw 'Dynamic OpenCL compilation failed' }
python scripts/test_opencl_runtime.py $DynamicExecutable
if ($LASTEXITCODE -ne 0) { throw 'Dynamic OpenCL fallback verification failed' }
- name: Package and probe Windows candidate
shell: pwsh
run: |
Expand Down
2 changes: 1 addition & 1 deletion compatibility.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"ci_sdk": "1.4.309.0",
"ci_runtime": "v1.2",
"modules": {
"opencl": "v1.0.2",
"opencl": "v1.1.0",
"vulkan": "v3.2.2",
"vkmemalloc": "v2.6.2",
"memory": "v1.4.0"
Expand Down
9 changes: 8 additions & 1 deletion docs/guide/07-compute-and-verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ records why. An OpenCL build can also fall back when no device is available or
when initialization or dispatch fails. The session owns its device resources
and `App.shutdown` closes it.

The [dynamic build](../technical-reference.md#optional-opencl-backend) also
reaches this fallback when the OpenCL loader is absent, before device discovery.
Available devices must support source compilation; selection prefers a GPU and
reports why candidates were rejected. Reusable scratch owners grow buffers only
after replacement allocation succeeds and release their retained context
references during cleanup.

Headless output reports the requested and active backend, verified and
mismatched batches, fallback counts, per-workload mismatches, collision
candidate counts and a compute checksum. Read these numbers together with
Expand Down Expand Up @@ -96,7 +103,7 @@ the CPU fallback remains part of the supported configuration.

The ordinary binary reports `compute_requested=opencl` but
`compute_active=cpu` when built without `-d opencl_compute`. The
[`compute_backend_test.v`](../../sim/compute_backend_test.v#L267) and
[`compute_backend_test.v`](../../sim/compute_backend_test.v#L271) and
[`scripts/check.sh`](../../scripts/check.sh) verify that fallback preserves
gameplay checksums. The optional `TT_OPENCL_SMOKE=1` gate requires a device
run and checks its batches. Those are correctness checks, not benchmarks.
Expand Down
9 changes: 8 additions & 1 deletion docs/technical-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ directly.
### Optional OpenCL backend

```sh
v -d opencl_compute -cc gcc -o torus_trooper .
v -d opencl_compute -d opencl_dynamic -cc gcc -o torus_trooper .
./torus_trooper --compute opencl
```

Expand All @@ -162,6 +162,13 @@ or produces a batch that differs from the CPU reference, the game uses the CPU
result and reports the fallback. See [Compute backends](#compute-backends) for
what is checked and which work remains on the CPU.

`-d opencl_dynamic` loads OpenCL at runtime, so an enabled executable can still
start and use the CPU when the loader is missing. Development headers are needed
at build time. Omit this flag to link the loader directly. Set `OPENCL_LIBRARY`
to an absolute library path to select a specific runtime; a failed override is
reported and falls back to CPU without trying another loader. Device selection
prefers an available GPU with a source compiler, then another capable device.

## Advanced settings and controls

### Option files and saved preferences
Expand Down
4 changes: 4 additions & 0 deletions scripts/check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ if [[ "${TT_OPENCL_SMOKE:-0}" == "1" ]]; then
grep -Fq 'compute_shot_mismatched_batches=0' <<<"$opencl_output"
grep -Fq 'compute_enemy_mismatched_batches=0' <<<"$opencl_output"
grep -Fq 'compute_checksum=3df697f628fcafc9' <<<"$opencl_output"

dynamic_binary="$build_dir/torus_trooper_opencl_dynamic"
python3 scripts/check_compiler_diagnostics.py -- v -d opencl_compute -d opencl_dynamic -cc gcc -o "$dynamic_binary" .
python3 scripts/test_opencl_runtime.py "$dynamic_binary" --require-opencl
fi

probe_output=$("$binary" --no-sound --volume 0 --res 800 600 --reverse --brightness 70 --luminosity 40 --probe)
Expand Down
59 changes: 59 additions & 0 deletions scripts/test_opencl_runtime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Verify optional OpenCL loading and deterministic fallback in a built game."""

import argparse
import os
from pathlib import Path
import subprocess
import sys
import tempfile


def run(binary, backend, environment):
result = subprocess.run(
[str(binary), '--headless', '--ticks', '600', '--compute', backend,
'--no-sound', '--volume', '0'],
env=environment, capture_output=True, text=True, timeout=60, check=True,
)
fields = dict(line.split('=', 1) for line in result.stdout.splitlines() if '=' in line)
assert fields['compute_requested'] == backend, result.stdout
return fields


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('binary', type=Path)
parser.add_argument('--require-opencl', action='store_true')
args = parser.parse_args()
binary = args.binary.resolve()
if sys.platform == 'linux':
linked = subprocess.check_output(['ldd', str(binary)], text=True)
assert 'libOpenCL' not in linked, linked

environment = dict(os.environ)
environment.pop('OPENCL_LIBRARY', None)
cpu = run(binary, 'cpu', environment)
available = run(binary, 'opencl', environment)
if args.require_opencl:
assert available['compute_active'] == 'opencl', available
assert int(available['compute_verified_batches']) > 0, available

with tempfile.TemporaryDirectory() as temporary:
missing = run(binary, 'opencl', dict(
environment, OPENCL_LIBRARY=str(Path(temporary) / 'missing-opencl-runtime'),
))
assert missing['compute_active'] == 'cpu', missing
assert 'OpenCL runtime unavailable' in missing['opencl_fallback'], missing
assert int(missing['compute_fallback_batches']) > 0, missing
assert missing['compute_verified_batches'] == '0', missing
checksums = ['checksum', 'render_checksum', 'course_checksum',
'course_fill_checksum', 'course_backward_checksum',
'course_backward_fill_checksum', 'compute_checksum']
for key in checksums:
assert missing[key] == cpu[key], (key, cpu[key], missing[key])
assert available[key] == cpu[key], (key, cpu[key], available[key])
print('Dynamic OpenCL execution and missing-loader CPU fallback preserve all checksums.')


if __name__ == '__main__':
main()
100 changes: 72 additions & 28 deletions sim/compute_backend_test.v
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ module sim

import math

$if opencl_compute ? {
import antono2.opencl as cl
}

fn test_cpu_particle_compute_matches_scalar_reference_and_compacts_slots() {
mut simulation := new_simulation(SimulationConfig{ particle_capacity: 4 })
simulation.particles[1] = Particle{
Expand Down Expand Up @@ -300,6 +304,33 @@ fn test_compute_session_explains_an_unlinked_opencl_fallback() {
}

$if opencl_compute ? {
fn opencl_context_references(context cl.Context) !u32 {
mut count := u32(0)
cl.check(cl.get_context_info(context, cl.context_reference_count, sizeof(count), &count,
unsafe { nil }), 'read test context reference count')!
return count
}

fn test_opencl_initial_dispatch_error_releases_unadopted_scratch() ! {
mut compute := new_opencl_compute() or {
eprintln('OpenCL dispatch cleanup test skipped: ${err}')
return
}
defer { compute.close() }
before := opencl_context_references(compute.context.handle)!
closed_queue := cl.OwnedCommandQueue{}
mut items := [OpenCLParticleMotion{ life: 3 }]
mut failed := false
run_motion_kernel(&compute.context, &closed_queue, &compute.particle_kernel,
unsafe { &cl.ScratchBuffer[OpenCLParticleMotion](nil) }, mut items) or {
failed = true
assert err.msg().contains('closed queue')
}
assert failed
assert opencl_context_references(compute.context.handle)! == before
assert isnil(compute.particle_buffer)
}

fn test_opencl_session_matches_all_cpu_motion_kernels() {
assert sizeof(OpenCLParticleSlot) == 24
assert sizeof(OpenCLBulletSlot) == 36
Expand Down Expand Up @@ -424,13 +455,13 @@ $if opencl_compute ? {
assert batch.active_shots == 2
assert batch.active_targets == 3
assert batch.tested_pairs == 6
assert compute.collision_shot_slots.count == shots.len
assert compute.collision_shots.count == 2
assert compute.collision_target_slots.count == enemies.len
assert compute.collision_targets.count == 3
assert compute.active_flags_a.count == 6
assert compute.active_flags_b.count == 6
assert compute.collision_candidate_buffer.count == 6
assert compute.collision_shot_slots.buffer()!.count == shots.len
assert compute.collision_shots.buffer()!.count == 2
assert compute.collision_target_slots.buffer()!.count == enemies.len
assert compute.collision_targets.buffer()!.count == 3
assert compute.active_flags_a.buffer()!.count == 6
assert compute.active_flags_b.buffer()!.count == 6
assert compute.collision_candidate_buffer.buffer()!.count == 6
}

fn test_opencl_generates_stable_shot_bullet_candidates() ! {
Expand Down Expand Up @@ -483,14 +514,14 @@ $if opencl_compute ? {
assert batch.active_shots == 1
assert batch.active_targets == 3
assert batch.tested_pairs == 3
assert compute.collision_shot_slots.count == shots.len
assert compute.collision_shots.count == 1
assert compute.collision_target_slots.count == bullets.len
assert compute.collision_targets.count == 3
assert compute.collision_shot_slots.buffer()!.count == shots.len
assert compute.collision_shots.buffer()!.count == 1
assert compute.collision_target_slots.buffer()!.count == bullets.len
assert compute.collision_targets.buffer()!.count == 3
// Scan buffers retain the largest slot count used earlier in the batch.
assert compute.active_flags_a.count == shots.len
assert compute.active_flags_b.count == shots.len
assert compute.collision_candidate_buffer.count == 3
assert compute.active_flags_a.buffer()!.count == shots.len
assert compute.active_flags_b.buffer()!.count == shots.len
assert compute.collision_candidate_buffer.buffer()!.count == 3
}

fn test_opencl_live_collision_candidate_validation_is_observational() {
Expand Down Expand Up @@ -658,12 +689,25 @@ $if opencl_compute ? {
lives: [3]
}
compute.step_particles(mut one)!
first_handle := compute.particle_buffer.handle
first_handle := compute.particle_buffer.buffer()!.handle
assert !isnil(first_handle)
assert compute.particle_buffer.count == 1
assert compute.particle_buffer.buffer()!.count == 1

compute.step_particles(mut one)!
assert compute.particle_buffer.handle == first_handle
assert compute.particle_buffer.buffer()!.handle == first_handle

// A rejected reservation must leave the owner and its contents usable.
mut rejected := false
ensure_buffer_capacity(&compute.context, compute.particle_buffer, -1) or {
rejected = true
assert err.msg().contains('negative')
}
assert rejected
assert compute.particle_buffer.buffer()!.handle == first_handle
mut retained := []OpenCLParticleMotion{len: 1}
compute.particle_buffer.buffer()!.read(&compute.queue, 0, mut retained)!
assert retained[0].angle == one.angles[0]
assert retained[0].life == one.lives[0]

mut three := ParticleMotionSoa{
source_indices: [0, 1, 2]
Expand All @@ -674,7 +718,7 @@ $if opencl_compute ? {
lives: [3, 4, 5]
}
compute.step_particles(mut three)!
assert compute.particle_buffer.count == 3
assert compute.particle_buffer.buffer()!.count == 3
}

fn test_opencl_compacts_particle_pool_in_stable_order() ! {
Expand Down Expand Up @@ -714,10 +758,10 @@ $if opencl_compute ? {

assert actual.source_indices == [1, 3, 6]
assert compare_particle_motion(expected, actual, 0.00001).matches()
assert compute.particle_slots.count == 7
assert compute.active_flags_a.count == 7
assert compute.active_flags_b.count == 7
assert compute.particle_indices.count == 7
assert compute.particle_slots.buffer()!.count == 7
assert compute.active_flags_a.buffer()!.count == 7
assert compute.active_flags_b.buffer()!.count == 7
assert compute.particle_indices.buffer()!.count == 7
}

fn test_opencl_compacts_bullet_pool_in_stable_order() ! {
Expand Down Expand Up @@ -763,8 +807,8 @@ $if opencl_compute ? {

assert actual.source_indices == [0, 4, 6]
assert compare_bullet_motion(expected, actual, 0.00001).matches()
assert compute.bullet_slots.count == 7
assert compute.bullet_indices.count == 7
assert compute.bullet_slots.buffer()!.count == 7
assert compute.bullet_indices.buffer()!.count == 7
}

fn test_opencl_compacts_only_released_shots_in_stable_order() ! {
Expand Down Expand Up @@ -805,8 +849,8 @@ $if opencl_compute ? {

assert actual.source_indices == [2, 5]
assert compare_shot_motion(expected, actual, 0.00001).matches()
assert compute.shot_slots.count == 7
assert compute.shot_indices.count == 7
assert compute.shot_slots.buffer()!.count == 7
assert compute.shot_indices.buffer()!.count == 7
}

fn test_opencl_compacts_prepared_enemies_in_stable_order() ! {
Expand Down Expand Up @@ -839,8 +883,8 @@ $if opencl_compute ? {

assert actual.source_indices == [1, 5]
assert compare_enemy_motion(expected, actual, 0.00001).matches()
assert compute.enemy_slots.count == 7
assert compute.enemy_indices.count == 7
assert compute.enemy_slots.buffer()!.count == 7
assert compute.enemy_indices.buffer()!.count == 7
}
}

Expand Down
Loading
Loading