Skip to content

Commit 076c704

Browse files
ci: add cross-platform determinism check (Part 1) (#1594)
* init determinism.yml file * integrated with existing ci * init determinism.yml file * switched test filter to generate coplanar.obj +trimmed output + -euo pipefail * minor fix * replaced nasty gear * small set of simple single-op determinism cases * Normalize line endings * hex-float OBJ dump mode * intermediate dumps for Perturb3 / boolean_result * format * canonicalize hex-float text in determinism compare * add Perturb3 decomposition lane + print stable hex from C++ dumps + dump full intermediate boolean_result array contents + keep simple-case chec * clang-format * boolean debug dump * Perturb3 decomposition * script to compare boolean dumps * binary-tree decomposition probes * minor fix * 4->1 * boolean3_000004 fix * clang format * boolean3_000015_post_winding_add_inQ.obj * trig canonicalization at 45 degree * added multicoplanar in blocking ci * added Boolean.NonIntersecting * add Boolean.AlmostCoplanar * reuse existing verbosity level and dedup dump helpers via boolean_dump.h * format * move determinism compare logic into scripts and keep dumps discovery-only * remove discovery * clean dead code * clean
1 parent c5f7213 commit 076c704

9 files changed

Lines changed: 354 additions & 31 deletions

File tree

.github/workflows/manifold.yml

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,20 @@ jobs:
8383
run: |
8484
cp ./build/lib/Release/* ./build/bin/Release
8585
./build/bin/Release/manifold_test.exe
86-
86+
- name: Export determinism meshes
87+
if: matrix.parallelization == 'OFF' && matrix.shared == 'OFF'
88+
shell: bash
89+
env:
90+
MANIFOLD_OBJ_HEX_FLOAT: 1
91+
run: |
92+
cp ./build/lib/Release/* ./build/bin/Release
93+
bash ./scripts/export_determinism_meshes.sh build/bin/Release ./manifold_test.exe meshes-windows
94+
- name: Upload determinism meshes (Windows)
95+
if: matrix.parallelization == 'OFF' && matrix.shared == 'OFF'
96+
uses: actions/upload-artifact@v4
97+
with:
98+
name: meshes-windows
99+
path: meshes-windows/
87100
build:
88101
name: GCC ${{matrix.gcc}} (CrossSection:${{matrix.cross_section}}, TBB:${{matrix.parallelization}})
89102
timeout-minutes: 45
@@ -145,6 +158,18 @@ jobs:
145158
export PYTHONPATH=$PYTHONPATH:$(pwd)/build/bindings/python
146159
python3 bindings/python/examples/run_all.py -e
147160
python3 -m pytest
161+
- name: Export determinism meshes
162+
if: matrix.parallelization == 'OFF' && matrix.gcc == 14 && matrix.os == 'ubuntu-24.04'
163+
env:
164+
MANIFOLD_OBJ_HEX_FLOAT: 1
165+
run: |
166+
bash ./scripts/export_determinism_meshes.sh build/test ./manifold_test meshes-linux
167+
- name: Upload determinism meshes (Linux)
168+
if: matrix.parallelization == 'OFF' && matrix.gcc == 14 && matrix.os == 'ubuntu-24.04'
169+
uses: actions/upload-artifact@v4
170+
with:
171+
name: meshes-linux
172+
path: meshes-linux/
148173
- name: test cmake consumer
149174
run: |
150175
sudo cmake --install build
@@ -425,6 +450,18 @@ jobs:
425450
- name: Test
426451
run: |
427452
build/test/manifold_test
453+
- name: Export determinism meshes
454+
if: matrix.parallelization == 'OFF' && matrix.shared == 'OFF' && matrix.os == 'macos-latest'
455+
env:
456+
MANIFOLD_OBJ_HEX_FLOAT: 1
457+
run: |
458+
bash ./scripts/export_determinism_meshes.sh build/test ./manifold_test meshes-mac
459+
- name: Upload determinism meshes (macOS)
460+
if: matrix.parallelization == 'OFF' && matrix.shared == 'OFF' && matrix.os == 'macos-latest'
461+
uses: actions/upload-artifact@v4
462+
with:
463+
name: meshes-mac
464+
path: meshes-mac/
428465
- name: test cmake consumer
429466
run: |
430467
sudo cmake --install build
@@ -491,3 +528,27 @@ jobs:
491528
- uses: DeterminateSystems/magic-nix-cache-action@main
492529
- run: nix build -L '.?submodules=1#${{matrix.variant}}'
493530

531+
determinism_check:
532+
name: Cross-platform determinism check
533+
needs: [build, build_windows, build_mac]
534+
runs-on: ubuntu-latest
535+
steps:
536+
- uses: actions/checkout@v6
537+
- name: Download Linux meshes
538+
uses: actions/download-artifact@v4
539+
with:
540+
name: meshes-linux
541+
path: meshes/linux
542+
- name: Download macOS meshes
543+
uses: actions/download-artifact@v4
544+
with:
545+
name: meshes-mac
546+
path: meshes/mac
547+
- name: Download Windows meshes
548+
uses: actions/download-artifact@v4
549+
with:
550+
name: meshes-windows
551+
path: meshes/windows
552+
- name: Compare meshes across platforms
553+
run: |
554+
bash ./scripts/compare_artifact.sh meshes

scripts/compare_artifact.sh

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
ROOT_DIR="${1:-meshes}"
5+
FILES="${DETERMINISM_COMPARE_FILES:-}"
6+
DIFF_LINES="${DETERMINISM_COMPARE_DIFF_LINES:-200}"
7+
NORMALIZE_SCRIPT="${DETERMINISM_NORMALIZE_SCRIPT:-./scripts/normalize_objs.sh}"
8+
9+
source "$(dirname "$0")/determinism_cases.sh"
10+
11+
if [ -z "$FILES" ]; then
12+
FILES="${DETERMINISM_OBJ_FILES[*]}"
13+
fi
14+
15+
for os in linux mac windows; do
16+
if [ ! -d "$ROOT_DIR/$os" ]; then
17+
echo "Missing directory: $ROOT_DIR/$os"
18+
exit 1
19+
fi
20+
done
21+
22+
tmpdir="$(mktemp -d)"
23+
trap 'rm -rf "$tmpdir"' EXIT
24+
25+
failed=0
26+
27+
for f in $FILES; do
28+
echo "--- $f ---"
29+
file_missing=0
30+
for os in linux mac windows; do
31+
if [ ! -f "$ROOT_DIR/$os/$f" ]; then
32+
echo "Missing file: $ROOT_DIR/$os/$f"
33+
failed=1
34+
file_missing=1
35+
fi
36+
done
37+
if [ "$file_missing" -ne 0 ]; then
38+
continue
39+
fi
40+
41+
for os in linux mac windows; do
42+
bash "$NORMALIZE_SCRIPT" "$ROOT_DIR/$os/$f" "$tmpdir/$os-$f"
43+
done
44+
45+
linux_hash="$(sha256sum "$tmpdir/linux-$f" | awk '{print $1}')"
46+
mac_hash="$(sha256sum "$tmpdir/mac-$f" | awk '{print $1}')"
47+
win_hash="$(sha256sum "$tmpdir/windows-$f" | awk '{print $1}')"
48+
echo "linux: $linux_hash"
49+
echo "mac: $mac_hash"
50+
echo "windows: $win_hash"
51+
52+
if [ "$linux_hash" != "$mac_hash" ] || [ "$linux_hash" != "$win_hash" ]; then
53+
echo "MISMATCH in $f"
54+
echo "linux vs mac diff:"
55+
diff "$tmpdir/linux-$f" "$tmpdir/mac-$f" | sed -n "1,${DIFF_LINES}p" || true
56+
echo "linux vs windows diff:"
57+
diff "$tmpdir/linux-$f" "$tmpdir/windows-$f" | sed -n "1,${DIFF_LINES}p" || true
58+
failed=1
59+
fi
60+
done
61+
62+
if [ "$failed" -ne 0 ]; then
63+
echo "::error::Cross-platform determinism check failed."
64+
exit 1
65+
fi
66+
67+
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
68+
{
69+
echo "### Cross-platform determinism check"
70+
echo ""
71+
echo "No cross-platform mismatches detected."
72+
} >> "$GITHUB_STEP_SUMMARY"
73+
fi

scripts/determinism_cases.sh

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#!/usr/bin/env bash
2+
3+
# Shared determinism test/filter definitions for CI export+compare.
4+
5+
DETERMINISM_GTEST_FILTER="Boolean.DeterminismSimpleSubtract:Boolean.DeterminismSimpleUnion:Boolean.DeterminismSimpleIntersect:Boolean.MultiCoplanar:Boolean.NonIntersecting:Boolean.AlmostCoplanar"
6+
7+
DETERMINISM_OBJ_FILES=(
8+
det_simple_subtract.obj
9+
det_simple_union.obj
10+
det_simple_intersect.obj
11+
det_multi_coplanar.obj
12+
det_non_overlap.obj
13+
det_nearly_coplanar.obj
14+
)
15+
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
if [ "$#" -ne 3 ]; then
5+
echo "Usage: $0 <run-dir> <test-binary> <output-dir>"
6+
exit 2
7+
fi
8+
9+
RUN_DIR="$1"
10+
TEST_BIN="$2"
11+
OUT_DIR="$3"
12+
13+
source "$(dirname "$0")/determinism_cases.sh"
14+
15+
repo_root="$(pwd)"
16+
if [[ "$OUT_DIR" = /* ]]; then
17+
out_abs="$OUT_DIR"
18+
else
19+
out_abs="$repo_root/$OUT_DIR"
20+
fi
21+
22+
cd "$RUN_DIR"
23+
"$TEST_BIN" -e --gtest_filter="$DETERMINISM_GTEST_FILTER"
24+
25+
mkdir -p "$out_abs"
26+
cp "${DETERMINISM_OBJ_FILES[@]}" "$out_abs/"

scripts/normalize_objs.sh

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
if [ "$#" -ne 2 ]; then
5+
echo "Usage: $0 <input-obj> <output-obj>"
6+
exit 2
7+
fi
8+
9+
input="$1"
10+
output="$2"
11+
12+
tr -d '\r' < "$input" > "$output"

src/boolean_result.cpp

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -739,7 +739,9 @@ Manifold::Impl Boolean3::Result(OpType op) const {
739739
// Create the output Manifold
740740
Manifold::Impl outR;
741741

742-
if (numVertR == 0) return outR;
742+
if (numVertR == 0) {
743+
return outR;
744+
}
743745

744746
outR.epsilon_ = std::max(inP_.epsilon_, inQ_.epsilon_);
745747
outR.tolerance_ = std::max(inP_.tolerance_, inQ_.tolerance_);

src/csg_tree.cpp

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
#endif
1818

1919
#include <algorithm>
20+
#include <cstdint>
2021

2122
#include "boolean3.h"
2223
#include "csg_tree.h"
@@ -28,10 +29,14 @@ namespace {
2829
using namespace manifold;
2930

3031
struct MeshCompare {
31-
bool operator()(const std::shared_ptr<CsgLeafNode>& a,
32-
const std::shared_ptr<CsgLeafNode>& b) {
32+
bool operator()(const std::pair<std::shared_ptr<CsgLeafNode>, uint64_t>& a,
33+
const std::pair<std::shared_ptr<CsgLeafNode>, uint64_t>& b) {
3334
// Use NumVert() which doesn't trigger transform application.
34-
return a->NumVert() < b->NumVert();
35+
const size_t aVert = a.first->NumVert();
36+
const size_t bVert = b.first->NumVert();
37+
if (aVert != bVert) return aVert < bVert;
38+
// Tie-break by insertion order so heap behavior is deterministic across
39+
return a.second < b.second;
3540
}
3641
};
3742

@@ -384,47 +389,59 @@ std::shared_ptr<CsgLeafNode> BatchBoolean(
384389
if (results.size() == 2)
385390
return SimpleBoolean(*results[0]->GetImpl(), *results[1]->GetImpl(),
386391
operation);
392+
std::vector<std::pair<std::shared_ptr<CsgLeafNode>, uint64_t>> heapNodes;
393+
heapNodes.reserve(results.size());
394+
for (size_t i = 0; i < results.size(); ++i) {
395+
heapNodes.emplace_back(std::move(results[i]), i);
396+
}
397+
results.clear();
398+
uint64_t nextSerial = heapNodes.size();
399+
387400
// apply boolean operations starting from smaller meshes
388401
// the assumption is that boolean operations on smaller meshes is faster,
389402
// due to less data being copied and processed
390403
auto cmpFn = MeshCompare();
391-
std::make_heap(results.begin(), results.end(), cmpFn);
392-
std::vector<std::shared_ptr<CsgLeafNode>> tmp;
404+
std::make_heap(heapNodes.begin(), heapNodes.end(), cmpFn);
405+
std::vector<std::pair<std::shared_ptr<CsgLeafNode>, uint64_t>> tmp;
393406
#if MANIFOLD_PAR == 1
394407
tbb::task_group group;
395408
// make sure the order of result is deterministic
396409
std::vector<std::shared_ptr<CsgLeafNode>> parallelTmp;
410+
std::vector<uint64_t> parallelSerial;
397411
for (int i = 0; i < 4; i++) parallelTmp.push_back(nullptr);
412+
for (int i = 0; i < 4; i++) parallelSerial.push_back(0);
398413
#endif
399-
while (results.size() > 1) {
400-
for (size_t i = 0; i < 4 && results.size() > 1; i++) {
401-
std::pop_heap(results.begin(), results.end(), cmpFn);
402-
auto a = std::move(results.back());
403-
results.pop_back();
404-
std::pop_heap(results.begin(), results.end(), cmpFn);
405-
auto b = std::move(results.back());
406-
results.pop_back();
414+
while (heapNodes.size() > 1) {
415+
for (size_t i = 0; i < 4 && heapNodes.size() > 1; i++) {
416+
std::pop_heap(heapNodes.begin(), heapNodes.end(), cmpFn);
417+
auto a = std::move(heapNodes.back());
418+
heapNodes.pop_back();
419+
std::pop_heap(heapNodes.begin(), heapNodes.end(), cmpFn);
420+
auto b = std::move(heapNodes.back());
421+
heapNodes.pop_back();
407422
#if MANIFOLD_PAR == 1
408-
group.run([&, i, a, b]() {
423+
parallelSerial[i] = nextSerial++;
424+
group.run([&, i, a = std::move(a.first), b = std::move(b.first)]() {
409425
parallelTmp[i] = SimpleBoolean(*a->GetImpl(), *b->GetImpl(), operation);
410426
});
411427
#else
412-
auto result = SimpleBoolean(*a->GetImpl(), *b->GetImpl(), operation);
413-
tmp.push_back(result);
428+
auto result =
429+
SimpleBoolean(*a.first->GetImpl(), *b.first->GetImpl(), operation);
430+
tmp.emplace_back(std::move(result), nextSerial++);
414431
#endif
415432
}
416433
#if MANIFOLD_PAR == 1
417434
group.wait();
418435
for (int i = 0; i < 4 && parallelTmp[i]; i++)
419-
tmp.emplace_back(std::move(parallelTmp[i]));
436+
tmp.emplace_back(std::move(parallelTmp[i]), parallelSerial[i]);
420437
#endif
421-
for (auto result : tmp) {
422-
results.push_back(result);
423-
std::push_heap(results.begin(), results.end(), cmpFn);
438+
for (auto& result : tmp) {
439+
heapNodes.push_back(std::move(result));
440+
std::push_heap(heapNodes.begin(), heapNodes.end(), cmpFn);
424441
}
425442
tmp.clear();
426443
}
427-
return results.front();
444+
return heapNodes.front().first;
428445
}
429446

430447
/**

src/impl.cpp

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
#include <algorithm>
1818
#include <atomic>
19+
#include <cstdio>
20+
#include <cstdlib>
1921
#include <cstring>
2022
#include <iomanip>
2123
#include <map>
@@ -700,16 +702,48 @@ void Manifold::Impl::IncrementMeshIDs() {
700702
static std::ostream& WriteOBJWithEpsilon(std::ostream& stream,
701703
const MeshGL64& mesh,
702704
std::optional<double> epsilon) {
705+
auto useHexFloat = []() {
706+
const char* v = std::getenv("MANIFOLD_OBJ_HEX_FLOAT");
707+
if (v == nullptr) return false;
708+
return std::strcmp(v, "1") == 0 || std::strcmp(v, "true") == 0 ||
709+
std::strcmp(v, "TRUE") == 0 || std::strcmp(v, "on") == 0 ||
710+
std::strcmp(v, "ON") == 0;
711+
};
712+
const bool hexFloat = useHexFloat();
713+
auto writeValue = [&](double value) {
714+
if (hexFloat) {
715+
// Use explicit C-format hex to keep text stable across standard library
716+
// implementations.
717+
char buf[128];
718+
std::snprintf(buf, sizeof(buf), "%.13a", value);
719+
stream << buf;
720+
} else {
721+
stream << value;
722+
}
723+
};
724+
703725
stream << std::setprecision(19); // for double precision
704-
stream << std::fixed; // for uniformity in output numbers
726+
if (!hexFloat) {
727+
stream << std::fixed; // for uniformity in output numbers
728+
}
705729
stream << "# ======= begin mesh ======" << std::endl;
706-
stream << "# tolerance = " << mesh.tolerance << std::endl;
707-
if (epsilon.has_value())
708-
stream << "# epsilon = " << epsilon.value() << std::endl;
730+
stream << "# float_format = " << (hexFloat ? "hexfloat" : "fixed")
731+
<< std::endl;
732+
stream << "# tolerance = ";
733+
writeValue(mesh.tolerance);
734+
stream << std::endl;
735+
if (epsilon.has_value()) {
736+
stream << "# epsilon = ";
737+
writeValue(epsilon.value());
738+
stream << std::endl;
739+
}
709740
for (size_t i = 0; i < mesh.NumVert(); i++) {
710741
stream << "v";
711742
size_t offset = i * mesh.numProp;
712-
for (size_t j : {0, 1, 2}) stream << " " << mesh.vertProperties[offset + j];
743+
for (size_t j : {0, 1, 2}) {
744+
stream << " ";
745+
writeValue(mesh.vertProperties[offset + j]);
746+
}
713747
stream << std::endl;
714748
}
715749
std::vector<ivec3> triangles;

0 commit comments

Comments
 (0)