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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Experimental alpha: APIs and quality/performance tradeoffs are still evolving.

Android: API 29+, Vulkan 1.1, arm64-v8a. The GPU path additionally checks subgroup and memory limits.
See the [Android releases](https://github.com/Xget7/splatkit-android/releases) for artifacts.
Maven `0.1.0-alpha04` predates GPU ordering; `0.1.0-alpha05` is the new GPU integration.
Maven `0.1.0-alpha04` predates GPU ordering; `0.1.0-alpha06` fixes Adreno sorting in alpha05.
To build current source:

```sh
Expand Down
128 changes: 128 additions & 0 deletions docs/benchmarks/2026-09-12-mi9-vulkan.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
{
"date": "2026-09-12",
"environment": "physical Xiaomi Mi 9",
"gpu": "Adreno 640",
"vulkan": "1.1.128",
"driver": "0x801f6000",
"android": "11 / MIUI 12.5.1",
"build": "minified Release, validation off",
"resolution": [
1080,
2261
],
"render_scale": 1,
"sh_degree": 0,
"sort_bits": 32,
"source": "modern_house_with_lush_landscaping_2m.spz",
"source_splats": 2000000,
"source_sha256": "fe32a5a66b7f5caaad793ab90a7ff7d7948f52ba360044170b4da04c3950ebad",
"collider_sha256": "a9ab04ad30947f85000fad460f7bcf7488dfb4b995a449a4124342c4b53d5467",
"camera": [
0,
0.73,
0,
0,
0
],
"benchmark": "30-second turn in place after world-ready; gyro off; mailbox presentation; Android host frame scheduling remains",
"lod": "load-time default hierarchy, 2970075 resident nodes, selection capacity 1200000; unchanged quality settings across tail-skip A/B",
"stage_timings": "Means of ~2-second completed-frame log samples, not per-frame averages; null means not instrumented",
"process_pss_kib": 579266,
"process_rss_kib": 686792,
"android_thermal_status": 0,
"acceptance": "No perceptual metric/reference-image acceptance or sustained thermal certification. LOD parents/subpixel rejection remain approximate; no universal FPS guarantee.",
"runs": [
{
"variant": "full_source",
"local_start": "09-12 18:06:16.349",
"frames": 578,
"fps_mean": 19.2,
"frame_mean_ms": 52,
"frame_p95_ms": 66.8,
"gpu_mean_ms": 51.9,
"gpu_p95_ms": 59.6,
"sort_sample_mean_ms": 3.147,
"cull_sample_mean_ms": null,
"select_sample_mean_ms": 0,
"sample_count": 15,
"drawn_sample_range": [
44983,
125217
],
"selected_sample_range": [
2000000,
2000000
],
"end_gpu_celsius": 58.3
},
{
"variant": "lod_before_tail_skip",
"local_start": "09-12 18:10:34.401",
"frames": 721,
"fps_mean": 24,
"frame_mean_ms": 41.7,
"frame_p95_ms": 50.1,
"gpu_mean_ms": 41.6,
"gpu_p95_ms": 50,
"sort_sample_mean_ms": 3.173,
"cull_sample_mean_ms": 19.147,
"select_sample_mean_ms": 3.64,
"sample_count": 15,
"drawn_sample_range": [
45310,
121828
],
"selected_sample_range": [
50909,
132148
],
"end_gpu_celsius": 58.7
},
{
"variant": "lod_after_tail_skip",
"local_start": "09-12 18:15:00.531",
"frames": 1243,
"fps_mean": 41.4,
"frame_mean_ms": 24.1,
"frame_p95_ms": 33.4,
"gpu_mean_ms": 23.9,
"gpu_p95_ms": 32.6,
"sort_sample_mean_ms": 3.18,
"cull_sample_mean_ms": 1.807,
"select_sample_mean_ms": 3.653,
"sample_count": 15,
"drawn_sample_range": [
44999,
124889
],
"selected_sample_range": [
52411,
136761
],
"end_gpu_celsius": 64.5
},
{
"variant": "lod_after_tail_skip",
"local_start": "09-12 18:16:05.115",
"frames": 1244,
"fps_mean": 41.4,
"frame_mean_ms": 24.1,
"frame_p95_ms": 33.4,
"gpu_mean_ms": 23.9,
"gpu_p95_ms": 32.5,
"sort_sample_mean_ms": 3.173,
"cull_sample_mean_ms": 1.813,
"select_sample_mean_ms": 3.64,
"sample_count": 15,
"drawn_sample_range": [
44920,
124804
],
"selected_sample_range": [
50891,
135651
],
"end_gpu_celsius": 63.3
}
]
}
4 changes: 2 additions & 2 deletions packages/splatkit-android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ An arm64 emulator can test functionality; it is not a phone performance measurem

## Install

New GPU integration: `0.1.0-alpha05`. Check [releases](https://github.com/Xget7/splatkit-android/releases) for publication status.
Adreno-corrected GPU integration: `0.1.0-alpha06`. Check [releases](https://github.com/Xget7/splatkit-android/releases) for publication status.
Maven `0.1.0-alpha04` is the older CPU-ordering artifact.

```kotlin
dependencies {
implementation("io.github.xget7:splatkit-android:0.1.0-alpha05")
implementation("io.github.xget7:splatkit-android:0.1.0-alpha06")
}
```

Expand Down
2 changes: 1 addition & 1 deletion packages/splatkit-android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ dependencies {
// publishing machine (ORG_GRADLE_PROJECT_mavenCentralUsername, mavenCentralPassword,
// signingInMemoryKey, signingInMemoryKeyPassword); local builds need none of it.
mavenPublishing {
coordinates("io.github.xget7", "splatkit-android", "0.1.0-alpha05")
coordinates("io.github.xget7", "splatkit-android", "0.1.0-alpha06")
publishToMavenCentral(automaticRelease = true)
if (project.findProperty("signingInMemoryKey") != null) signAllPublications()
pom {
Expand Down
23 changes: 22 additions & 1 deletion packages/splatkit-android/docs/VULKAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

Public API: `SplatSurfaceView`; JNI and Vulkan classes are internal.
Current source enables `VulkanFrameCompute`: GPU LOD → visibility → stable radix → indirect draw.
Alpha05 integrates this path; Maven alpha04 predates it. Hybrid tiles and RN GPU options remain pending.
Alpha06 fixes Adreno histogram corruption in alpha05; Maven alpha04 predates GPU ordering.
Hybrid tiles and RN GPU options remain pending.

Contracts live beside code: [VisibilityPass.h](../src/main/cpp/rendering/vulkan/VisibilityPass.h), [shader ABI](../src/main/cpp/rendering/vulkan/VulkanShaderTypes.h).
The context outlives allocations; reuse slots after fences, resize/destroy after all consumers finish.
Expand All @@ -21,6 +22,26 @@ native suites had layers off. Kitchen 500k loaded/drew with layers on and no cap
SwiftShader passes smaller suites but crashes on large mapped uploads, also reproduced without SDK code.
No physical Android performance or reference-image quality acceptance follows.

Physical Mi 9 / Adreno 640, Vulkan 1.1.128, driver 0x801f6000:
alpha05 ballot grouping repeatedly lost duplicate keys at 31 inputs.
Workgroup-local atomic histogram fixes this without global atomics.
Alpha06 passes 152 stable-sort cases through 3M (including 63/64/65 tails), visibility,
LOD, complete indirect ordering and upload-pressure tests; standalone layers were off.
Kitchen 500k and house 2M render with app validation on; background/resume also passed.
This is device correctness evidence, not lossless LOD or universal FPS acceptance.

Mi 9 house 2M, SH0, 1080x2261, Release, 30-second turns:

| Configuration | Mean FPS | GPU ms | Visibility ms* |
|---|---:|---:|---:|
| Full source | 19.2 | 51.9 | unavailable |
| LOD before empty-group skip | 24.0 | 41.6 | 19.15 |
| Same LOD, empty groups skipped (two runs) | 41.4 | 23.9 | 1.81 |

*Stage means use ~2-second samples. Sort stayed ~3.18 ms; source 2M, drawn ~45k–125k.
LOD quality/resolution were unchanged by this optimization. PSS snapshot: ~566 MiB.
[Settings, hashes and measurements](../../../docs/benchmarks/2026-09-12-mi9-vulkan.json).

## Metal mapping

| Metal | Vulkan / GLSL |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ RadixSort::Capabilities RadixSort::queryCapabilities(const VulkanContext& ctx) {
result.reason = "descriptor/range/dispatch limits too small";
} else {
result.supported = true;
result.reason = "stable subgroup histogram and workgroup bit-mask scatter available";
result.reason = "shared histogram and stable workgroup bit-mask scatter available";
}
return result;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ void VulkanFrameCompute::collect(uint32_t slot) {
: (uint64_t{1} << timestampBits_) - 1;
const double millis = static_cast<double>(timestampPeriod_) * 1e-6;
stats_.selectMillis = lod_ ? ((ticks[1] - ticks[0]) & mask) * millis : 0;
stats_.cullMillis = ((ticks[2] - ticks[1]) & mask) * millis;
stats_.sortMillis = ((ticks[3] - ticks[2]) & mask) * millis;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ class VulkanFrameCompute {
};
struct Stats {
uint32_t drawn = 0, selected = 0, limited = 0, evaluated = 0, status = 0;
double sortMillis = 0, selectMillis = 0;
double sortMillis = 0, selectMillis = 0, cullMillis = 0;
};
// Nullopt means encoding failed; caller must still submit/end its acquired frame.
std::optional<Draw> encode(VkCommandBuffer cmd, uint32_t slot, VkBuffer camera,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ class VulkanSplatRenderer final : public SplatRenderer {
bool draw(const Frame& frame) override;
double lastGpuMillis() const override { return frameLoop_.lastGpuMillis(); }
double lastSortMillis() const override { return compute_ ? compute_->stats().sortMillis : 0; }
double lastCullMillis() const override { return compute_ ? compute_->stats().cullMillis : 0; }
double lastSelectMillis() const override { return compute_ ? compute_->stats().selectMillis : 0; }
uint32_t lastDrawCount() const override { return compute_ ? compute_->stats().drawn : 0; }
uint32_t lastSelectedCount() const override { return compute_ ? compute_->stats().selected : 0; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,6 @@ layout(set=0,binding=8,std430) buffer CountOut { uint countOut; };
layout(set=0,binding=9,std430) buffer Status { uint status; };
layout(push_constant) uniform Push { uint capacity, shift, maxGroupsX; } pc;

uvec4 matchDigit(uint digit, bool valid) {
uvec4 peers = subgroupBallot(valid);
for (uint bit=0; bit<8; ++bit) {
bool set = ((digit >> bit) & 1u) != 0;
uvec4 vote = subgroupBallot(set);
peers &= set ? vote : ~vote;
}
return peers;
}
uint element(uint block, uint row) {
return block*2048u + row*128u + gl_LocalInvocationIndex;
}
Expand All @@ -39,10 +30,9 @@ void main() {
uint e=element(block,row);
bool valid=e<countOut;
uint digit=valid ? (keysIn[e] >> pc.shift)&255u : 0u;
uvec4 peers=matchDigit(digit,valid);
// One shared atomic per distinct digit in a subgroup, no global counter contention.
if(valid && subgroupBallotFindLSB(peers)==gl_SubgroupInvocationID)
atomicAdd(bins[digit],subgroupBallotBitCount(peers));
// Workgroup-local atomics only; global bins have one writer per block.
// Ballot digit grouping loses duplicate keys on the Adreno 640 / 1.1.128 driver.
if(valid) atomicAdd(bins[digit],1u);
}
barrier();
histogram[t*blocks+block]=bins[t];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,11 @@ bool resolveCandidate(uint logical, out uint source, inout uint errors) {
void main() {
uint groupIndex = gl_WorkGroupID.x + gl_WorkGroupID.y * constants.dispatchGroupsX;
uint logical = groupIndex * gl_WorkGroupSize.x + gl_LocalInvocationID.x;
// Uniform workgroup exit: indexed LOD usually fills only a fraction of its capacity.
// Keep group zero for empty-input/projection diagnostics, and never hide invalid counts.
if (constants.mode == 1u && groupIndex > 0u &&
candidateCount <= constants.candidateCapacity &&
groupIndex * gl_WorkGroupSize.x >= candidateCount) return;
uint sourceIndex = 0u, errors = 0u, key = 0u;
if (constants.mode == 2u && logical < constants.rangeCount && !validRange(logical)) errors |= 8u;
bool candidate = resolveCandidate(logical, sourceIndex, errors);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ int main() {
bad.keys = sort->output(1).keys;
require(!sort->encode(cmd, 0, bad), "reject owned output alias");
});
std::vector<uint32_t> sizes{0, 1, 31, 32, 33, 67, 127, 128, 129, 257};
std::vector<uint32_t> sizes{0, 1, 31, 32, 33, 63, 64, 65, 67, 127, 128, 129, 257};
if (!tiny) sizes.insert(sizes.end(), {2047, 2048, 2049, 4097, 262145});
if (large && !tiny) sizes.push_back(capacity);
std::mt19937 random(0x51a7u);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,34 @@ void tests(const test::VulkanTestContext& gpu) {
}
std::puts("PASS prefix, empty, subgroup tails and descriptor slots");

// LOD reserves far more entries than it emits. Poison inactive groups so reading
// a capacity tail instead of the GPU count becomes a visible diagnostic failure.
std::vector<uint32_t> sparseIndices(4097, UINT32_MAX);
std::iota(sparseIndices.begin(), sparseIndices.begin() + 300, 0);
auto sparseBuffer = upload(gpu, sparseIndices);
auto sparseCount = upload(gpu, std::vector<uint32_t>{0});
auto sparse = f.input();
indexed(sparse, *sparseBuffer, *sparseCount, 4097);
for (uint32_t n : {0u, 1u, 127u, 128u, 129u, 257u}) {
require(sparseCount->upload(&n, 4), "sparse GPU count");
std::vector<uint32_t> expected(n);
std::iota(expected.begin(), expected.end(), 0);
membership(run(gpu, *pass, sparse), expected);
membership(run(gpu, *pass, sparse, 1), expected);
}
uint32_t sparseInvalid = 4098;
require(sparseCount->upload(&sparseInvalid, 4), "sparse invalid count");
failed(run(gpu, *pass, sparse), VisibilityPass::kInvalidCount);
sparseInvalid = 0;
require(sparseCount->upload(&sparseInvalid, 4), "sparse empty count");
auto invalidProjection = camera();
invalidProjection.proj = splat::Mat4::identity();
f.setCamera(invalidProjection);
sparse.keyBits = VisibilityPass::KeyBits::low16;
failed(run(gpu, *pass, sparse), VisibilityPass::kInvalidProjection);
f.setCamera(camera());
std::puts("PASS sparse indexed workgroups, poisoned tails and empty diagnostics");

require(pass->reserve(3), "small output capacity");
auto indexBuffer = upload(gpu, std::vector<uint32_t>{299, 0, 128});
auto countBuffer = upload(gpu, std::vector<uint32_t>{3});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,9 @@ class SplatRenderer {
// GPU time of the most recently completed frame, from timestamps at both ends of it.
// Zero until the first frame completes or if unsupported.
virtual double lastGpuMillis() const = 0;
// GPU time of the last visibility pass, when the renderer sorts on the GPU.
// GPU times of completed sort and visibility passes; zero when unavailable.
virtual double lastSortMillis() const { return 0; }
virtual double lastCullMillis() const { return 0; }
// Splats the last frame drew, when the renderer sorts on the GPU.
virtual uint32_t lastDrawCount() const { return 0; }
virtual uint32_t lastSelectedCount() const { return 0; }
Expand Down
2 changes: 1 addition & 1 deletion packages/splatkit-engine/src/engine/SplatEngine.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ void SplatEngine::streamTiles(const FrameCamera& camera, float pixelScale,
void SplatEngine::takeSortResult() {
if (gpuSort_) {
lastSort_.sortMillis = renderer_->lastSortMillis();
lastSort_.cullMillis = 0;
lastSort_.cullMillis = renderer_->lastCullMillis();
lastSort_.selectMillis = renderer_->lastSelectMillis();
lastSort_.selected = streamer_ ? streamer_->drawnSplats() : sourceCount_;
drawCount_ = renderer_->lastDrawCount();
Expand Down
21 changes: 17 additions & 4 deletions scripts/sdk_harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,24 @@ def android_log(contents, pid, expected, environment):
if (match := line_pattern.match(line)) and int(match[1]) == pid]
text = "\n".join(messages)
errors = [line for line in messages if re.search(
r"Fatal signal|FATAL EXCEPTION|VUID-|Validation Error|World failed:|VK_ERROR_", line)]
r"Fatal signal|FATAL EXCEPTION|VUID-|Validation Error|World failed:|VK_ERROR_|Vulkan GPU frame rejected:", line)]
device = re.search(r"Vulkan device: (.+)", text)
draws = re.findall(r"(\d+) drawn of (\d+) selected of (\d+)", text)
valid_draw = any(0 < int(drawn) <= int(selected) <= int(loaded) == expected
for drawn, selected, loaded in draws)
# Source readiness identifies the world; an uploaded LOD hierarchy has more
# resident records than original splats. Require a draw after its ready event.
resident = expected
ready = False
valid_draw = False
for message in messages:
upload = re.search(r"uploaded (\d+) splats", message)
if upload:
resident, ready = int(upload[1]), False
world = re.fullmatch(r"world ready: (\d+) splats", message)
if world:
ready = int(world[1]) == expected
draw = re.search(r"(\d+) drawn of (\d+) selected of (\d+)", message)
if ready and draw:
drawn, selected, records = map(int, draw.groups())
valid_draw |= 0 < drawn <= selected <= records == resident
checks = {"vulkan_initialized": device is not None,
"world_ready": f"world ready: {expected} splats" in messages,
"nonempty_draw": valid_draw, "no_logged_errors": not errors}
Expand Down
17 changes: 17 additions & 0 deletions scripts/tests/test_sdk_harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,23 @@ def test_empty_wrong_world_and_incomplete_logs_fail(self):
def test_validation_error_fails(self):
self.assertEqual(self.verdict(self.ready() + log("VUID-vkCmdDraw-None-02700"))["status"], "failed")

def test_lod_residency_is_not_original_source_count(self):
text = (log("Vulkan device: Adreno 640")
+ log("uploaded 750000 splats in 10 ms, sh degree 0")
+ log("world ready: 500000 splats")
+ log("100 drawn of 150 selected of 750000"))
self.assertEqual(self.verdict(text)["status"], "passed")
self.assertEqual(self.verdict(text.replace("of 750000", "of 800000"))["status"], "failed")

def test_previous_world_draw_cannot_validate_new_load(self):
text = (log("Vulkan device: Adreno 640")
+ log("100 drawn of 500000 selected of 500000")
+ log("world ready: 500000 splats"))
self.assertEqual(self.verdict(text)["status"], "failed")

def test_gpu_rejection_fails_even_after_a_valid_draw(self):
self.assertEqual(self.verdict(self.ready() + log("Vulkan GPU frame rejected: diagnostic bits 0x1"))["status"], "failed")

def test_plan_never_installs_or_launches(self):
steps = harness.plan("android", 2)
self.assertEqual(steps[0]["argv"], ["./gradlew", "--console=plain",
Expand Down
Loading