Retrospective: M1a (walking skeleton, CPU sssp)#
Status: complete (2026-09-27), revised after the independent review. Steps 1-4 each appended their section; Step 5 (close-out) added the milestone summary, the acceptance record, the lessons and the re-estimate of the roadmap; Step 6 records the fixes of the 25 findings of the independent review and updates the summary, the record and the open items. Statements of Steps 1-5 that the review showed to be wrong are corrected in place and marked “(corrected in Step 6)”.
Step 1: repository bootstrap and M0 drafts (2026-09-27)#
Done#
Local repository (branch
main; the orchestrator later added the remoteorigin=dyng-dev/dyngand pushes; implementation steps never push), local git identity, configuration files (.clang-format,.clang-tidy,.editorconfig,.gitattributes,.pre-commit-config.yaml,.codespellrc,REUSE.toml).environment.yml(conda envdyng-dev: Python 3.12, CMake 4.4, Ninja, clang-format and clang-tidy 22.1.8, Doxygen, pre-commit, reuse, pytest, numpy, build, twine) andscripts/dev_env.sh. The env was created on the development machine and the conda cache cleaned.CMake build (presets
dev,release,relwithdebinfo,cpu-only,asan,tsan,parity), install/export (find_package(dyng)verified with a shared and a static build).Minimal core with unit tests (62 test cases; green on
dev,cpu-only,asan,tsan, and with OpenMP off); the header self-containment check; the Doxygen check with warnings as errors.Community and legal drafts, ADRs 0001, 0002, 0004, 0014; the
dyng0.0.1 name-reservation package andrelease.yml;ci/check.sh,lint.yml,cpu.yml,dependabot.yml.
Deviations from the plan (pragmatic choices, same intent)#
Plan |
What was done |
Why |
|---|---|---|
|
also |
|
|
|
there is no |
one |
one GoogleTest executable per module ( |
keeps rebuilds small and labels per module; CTest sees the same test cases |
|
OFF by default; ON fails at configure time with a clear message |
the CUDA backend arrives in M1b; the option exists so presets and scripts keep their shape |
|
|
an unqualified |
|
|
glibc may define macros |
|
-1 for host backends |
a host backend has no device; documented |
|
a small bootstrap that downloads the pinned CPM release with a SHA-256 check |
the plan forbids vendoring third-party code |
|
|
the next release is 0.1.0; the name-reservation package has its own version 0.0.1 |
gersemi (CMake formatter) in pre-commit |
not yet |
not required by M1a (“a minimal pre-commit”); add with the M4 infrastructure |
|
not yet created; |
created with the first oracle ( |
Notes for the next steps#
Add library code as a module with
dyng_add_module(NAME <module> SOURCES ...)incpp/CMakeLists.txt; tests withdyng_add_test(NAME ... SOURCES ... LABELS cpu <module>).Stage timing:
dyng::scoped_stage stage(res, "sssp.loop");(validated<algo>.<hook>names; CSV output is compatible with the MOSP--timingCSV).Preconditions:
DYNG_EXPECTS(cond, "message ", value, ...)(at least one message argument).ci/check.shis the gate;DYNG_CHECK_SKIP="precommit"skips steps that need the network.The
paritypreset setsCMAKE_CXX_FLAGS_RELEASE=-O3(MOSP-OpenMP’s Makefile uses-std=c++17 -Wall -Wextra -O3 -fopenmp).
Process note#
Commit
dc56e9f(“test: skip the impossible-allocation test under sanitizers”) also contains.github/dependabot.yml, thename-reservationjob oflint.ymland the first version of this retrospective, because they were staged together by mistake. History is not rewritten; this note records it.
Step 2: graph container, batches and I/O (2026-09-27)#
Done#
graph<V,E,W>(pimpl, move-only, host storage behinddetail::graph_implso the device layout of M1b can be added without changing the public class): compact rows, objective-major weight columns (weights[k * m + e]), stored in-edges (the transposition of MOSP’stransposeCsrGraph(), deterministic order), version counter,view()(graph_viewwithout/inascsr_view),clone,reserve,to_csr,check_integrity.graph_propertieswithrow_order,multi_edges,batch_semanticsandmosp_compatible();apply_summary;csr/csr_view,edge_list/edge_list_view,edge_batch/edge_batch_view. ADR 0010 (draft) records the semantics.graph::apply= MOSP’sapplyChangeBatch()ported straight (a new CSR per batch; provenance header incpp/src/graph/apply_host.cpp), with the batch_semantics switches around the unchanged core.detail::graph_access::apply(res, g, batch, &delta)also returnsdetail::apply_delta: the effective insertions and deletions and one weight-increase byte per (insertion, objective), i.e. MOSP’sweightIncreaseMaskwithout the K <= 32 limit.io:read/write_csr_triplet,read/write_legacy_batch(insert.txt / delete.txt),read/write_distances,read/write_parents,read/write_matrix_marketwith seeded random weights. Privateutil/parser(strict tokens,io_errorwith path:line:column),util/text_writer(MOSP’s TextWriter),util/rng(the libstdc++ >= 11uniform_int_distributionalgorithm reproduced, somtx2csrweights do not depend on the standard library).docs/api/file_formats.mddescribes the formats.Tests (59 new cases; 121 in total): construction and semantics, a randomized comparison of
applywith an independent row-list model (300 graphs x 4 batches x 3 type combinations, all semantics switches except the error policies, which have their own tests), the MOSP input-validation cases re-expressed, format and round-trip tests, and byte parity with MOSP-OpenMP@c352151 on 27 apply fixtures (updated CSR, transposed CSR and the weight-increase flags; plusupdateGraphCSRas an edge set on the 10 generateTestCases cases) and 3mospPrep mtx2csrfixtures. The fixture tests were checked to fail on a mutated expected file.The fixtures (345 KB) are produced by
parity/fixtures/graph_io/make_graph_io_fixtures.shfrom agit archiveof the pinned original under$DYNG_SCRATCH/runs/graph-io-fixtures; the script is deterministic (a second run leavesgit statusclean).One-off check on roadNet-CA (K = 3, 50K batch
changes_50000_50): the updated CSR written by dynG is byte-identical to the original’sapplyChangeBatch()+writeCsrGraph(). Informal timings (not under the perf lock, cpu-only preset, one thread): dynG apply 58 ms + transpose 59 ms vs the original’s applyChangeBatch 67 ms + transposeCsrGraph 56 ms; reading the text CSR 538 ms vs 394 ms (the original parses the three files concurrently).Green:
ci/check.sh(cpu-only, dev, clang-format, REUSE, Doxygen, pre-commit), asan and tsan.
Deviations from the plan (pragmatic choices, same intent)#
Plan |
What was done |
Why |
|---|---|---|
|
|
MOSP keeps self-loops; two different “defaults” would be a trap (ADR 0010) |
|
plus |
|
|
|
avoids a mandatory, redundant setting for every multi-objective graph |
presets |
not yet (vertex operations throw |
they belong to M2 ( |
|
edge-major (file layout), like |
the text formats are edge-major; the engines read one column at a time |
readers “re-express” MOSP’s validation |
different accept/reject decisions on malformed files (corrected in Step 6; this row first said “same decisions”): extra integers on batch lines and delete lines with a single integer are errors (MOSP ignores them); |
strict parsing was the goal; on well-formed files (everything the original tools write) the results are identical. PLAN 8.3 requires MOSP’s rejection semantics on the compatibility path: since Step 6 |
Matrix Market |
mirrored like |
the Matrix Market specification; mospPrep treated it as general (no benchmark graph is hermitian) |
|
added |
the fixtures were needed before |
|
not in this step |
not needed by |
Notes for the next steps#
sssp::updateshould calldetail::graph_access::apply(res, g, batch, &delta)(private headercpp/src/graph/graph_impl.hpp); per objective k the MOSP “changed” edges aredelta.delete_*plus the insertions withdelta.weight_increased[i * K + k] != 0, and the insert heads aredelta.insert_dst.delta.delete_*lists every requested in-range deletion, matched or not, exactly like MOSP’schangedFrom/To, soinvalidatedstays comparable.MOSP’s
maxWeight[k]is the largest weight of the ORIGINAL graph and of the batch insertions, andweightSum[k](fordefaultDelta) is over the ORIGINAL graph: compute both fromg.view()BEFORE applying the batch.g.view().out.weight_column(k)andg.view().in.weight_column(k)are MOSP’sHostCsr::weightsfor objective k (no copy); the in-edge order differs from MOSP-OpenMP’s atomics-built reverse graph, which is schedule-dependent anyway; results are canonical.The profiler stage
graph.applycovers apply + transposition (MOSP timesapplyBatchseparately and puts the reverse graph intoprepare); map it inparity/timed_regions/sssp.toml.Distance / tree files:
io::read_distances<std::int64_t>,io::read_parents<vertex_t>,io::write_distances,io::write_parentsare byte-compatible with MOSP.
Process note#
The fresh-clone run of
ci/check.shcaught two fixture inputs that the repository-wide*.mtxignore rule had hidden (commit208a593exemptscpp/tests/data). Always run the gate in a fresh clone before reporting a step as done.
Open items#
The text CSR reader is ~35 % slower than MOSP’s (sequential vs three files in parallel). It is outside every timed region; parallel parsing can come with the shared parser (CycleEnum
from_chars+ threads) in M2.
Step 3: sssp on the CPU backends (2026-09-27)#
Done#
Public API (
<dyng/sssp.hpp>, PLAN Section 5.1):options,stats(derived from the newupdate_statsin<dyng/core/stats.hpp>), the opaque move-onlyresult(distances, parents, options, graph version, workspace;clone,set_options,from_arrayswith canonicalization andvalidate_inputs),compute()andupdate();stale_result_errorwhen the result does not match the graph’s version.<dyng/update.hpp>:dyng::update(res, g, batch, results...)anddyng::update_each(res, g, batch, list)overdetail::run_update()(all before-apply work on G_t, one commit, all updates on G_{t+1}; a result that fails after the commit is poisoned).OpenMP backend (
cpp/src/algorithms/sssp/openmp.cpp): MOSP-OpenMP@c352151sospUpdateCpu/sospFromScratchCpuported straight into the hooks identify_affected / seed / loop / finalize (provenance header;util/list_gather.hppfromlistGather.h). Sequential backend (sequential.cpp):sequentialSOSPUpdateadapted into the same hooks;compute()runs the same loop from the source. Profiler stagessssp.compute,sssp.reset,sssp.update,sssp.prepare,sssp.commit,sssp.identify_affected,sssp.seed,sssp.loop,sssp.finalize(update.commitfor the multi-result update).dyng::testing(installed targetdyng::testing):dijkstra()andcheck_sssp_tree()(ports ofdijkstraCsrGraphandcheckSospTree; in-edges rebuilt locally).Tests (60 new CTest cases; 181 on
dev): hand cases, the regressions (count-to-infinity n = 6 seeds 621705 / 250813: d(1) = 90; the ESCHER 4-vertex case d = 100, 101; ties; delete-all), randomized differential tests (5 shapes x 3 scenarios x 3 index types, 3-5 batches, both backends bit-equal, canonical check against Dijkstra and against a fresh compute), stale-result and validation tests, and byte parity with MOSP-OpenMP@c352151 on 34 fixture cases (every objective, sequential and OpenMP with 1/2/4 threads, 3 index types;invalidatedequal to the original’s counter). The fixtures (39 KB,cpp/tests/data/mosp_sssp) come fromparity/fixtures/sssp/make_sssp_fixtures.sh, which first checks that four original implementations agree byte for byte (mosp,parallelSOSPUpdate,sequentialSOSPUpdate,mospPrep expected). Two mutations (no root marking in the OpenMP invalidation; no children walk in the sequential one) each fail dozens of tests.tools/compat/dyng-compat-mosp(drop-in clone of the per-objective part of MOSP’smospdriver and ofmospPrep init; CTest compares its files with the fixtures),examples/cpp/sssp_update.cpp(run by CTest),parity/timed_regions/sssp.toml, ADR 0006 (draft),docs/algorithms/sssp.md, the sssp manifest.Green:
dev181/181,cpu-only,asan(all pass),tsan(OpenMP off, see below), a build withDYNG_ENABLE_OPENMP=OFF(162/162), Doxygen, REUSE, clang-format.
Informal check on roadNet-CA (K = 3, changes_50000_50, 28 threads pinned, perf lock)#
Not the M1a A/B deliverable (that belongs to the parity step), but a regression check of the port:
dyng-compat-mosp(parity preset) and the originalmosp(-O3, git-archive copy) write byte-identicalobj<k>/distancesUpdated.txtandSSSPTreeUpdated.txt, with equalinvalidatedcounters (1651102 / 1105903 / 1007744).sssp::computeon both backends writes files byte-identical to MOSP-OpenMP’smospPrep initfor all three objectives.Per-objective SOSP region (median of 7 alternating runs, ms): original 35.3 / 23.5 / 22.5, dynG 27.8 / 23.5 / 22.6. Before the parallel transposition dynG’s first objective was about 17 % slower (37.9 vs 32.5) although the kernel is at parity: with
OMP_WAIT_POLICY=activeboth were equal (23.7 vs 25.5 / 22.4 vs 22.6 / 22.2 vs 22.0). The cause was idle cores: dynG transposed the graph sequentially insidegraph.apply, MOSP builds its reverse graph in parallel right before the first objective. The transposition is now parallel on the OpenMP backend (deterministic, identical result), which also cutgraph.applyfrom about 135 ms to about 110 ms (MOSP: apply 82 ms + prepare 114 ms).Also found: the workspace lists of a fresh result took their first-touch page faults inside the timed region of every objective (MOSP shares one workspace across objectives, so only its first objective pays them).
reserve()now touches the pages once, at compute / from_arrays / clone. (Corrected in Step 6: this moved the cost out of the timed region instead of removing it; the original’s objective 0 still pays it inside the region. The pre-touch is now its own profiler stage, the A/B counts it for objective 0, and results sharing one workspace is an M1b blocker.)The sequential compute takes about 0.55 s per objective on roadNet-CA (MOSP’s Dijkstra in
mospPrep init: about 0.55 s); the OpenMP compute about 60 ms.End to end, dynG is slower (about 1.7 s vs 1.2 s) because it reads the graph and the 2K tree files one after another (the original reads them concurrently; see Step 2’s open item) and validates the imported trees.
Deviations from the plan (pragmatic choices, same intent)#
Plan |
What was done |
Why |
|---|---|---|
Section 5.1: |
also |
the number of objectives is a run-time value (the compat driver, later |
|
not |
a moved-from result has no options to return |
|
vertices whose distance or parent changed (deterministic); the OpenMP unpack pass counts it (one reduction added to the straight port) |
C1 needs |
|
OpenMP reports |
the CPU backends have no engine option; the two values tell the tier apart |
sequential backend = |
its classification of weight increases is the graph’s ( |
one classification for all backends keeps |
sequential |
the static enactor: the same re-scan loop from the source (reset -> seed_static -> loop -> finalize) |
it is the framework’s compute path and stays independent of |
|
also distance range, unreachable vertices without parents, reachable vertices with reachable parents; parent ids are always range-checked (memory safety), with or without validation |
MOSP tolerated some of these silently; imported trees are the only way a corrupt tree can enter |
the graph’s in-edges |
sssp requires |
building a transposition per call would hide a large cost |
|
explicit instantiations at the end of |
each file instantiates what it defines; a separate file would need the definitions in a header |
|
|
|
examples compiled against the installed package (Section 9.6) |
compiled in-tree ( |
the install test with |
|
|
GCC’s libgomp is not instrumented: every OpenMP barrier and reduction is reported as a race (verified). OpenMP race checking needs Archer (clang + llvm-openmp), not in the environment |
sssp in the parity step |
|
the timing map must exist before the port (Section 6.3 step 3); the byte-parity fixtures were needed by the tests |
graph module |
parallel deterministic transposition for the OpenMP backend |
found by the roadNet-CA check above |
Notes for the next steps#
Parity harness:
dyng-compat-mosp(built by thedevandparitypresets) reads themospinputs and writes the sameobj<k>files; its RESULT line and--timingCSV map to MOSP’s stages asparity/timed_regions/sssp.tomldescribes.parity/fixtures/sssp/make_sssp_fixtures.shcan be folded into the golden export (like the graph/io script); its archive is$DYNG_SCRATCH/runs/sssp-fixtures(12 MB, with a builtmosp,mospPrepand the two exporters).The initial trees under
$DYNG_SCRATCH/datasets/mosp/*/initare not canonical (they differ from MOSP-OpenMP’smospPrep initin some parents, e.g. vertex 902 of roadNet-CA objective 0: 959 vs 907; they were prepared with another tool). Without--canonicalizebothmospanddyng-compat-mospkeep the parents of untouched vertices, but (corrected in Step 6) their outputs agreed byte for byte only on the OpenMP backend: the sequential backend re-scanned the in-neighbours of every candidate and switched to lower-id tight parents (1-2 parent lines per objective on roadNet-CA). Since Step 6 every backend usessospUpdateCpu’s tie rule (ADR 0006). Golden trees that are meant to equal Dijkstra should be regenerated withmospPrep init(or both sides run with--canonicalize).The OpenMP A/B should keep the same thread placement for both sides (
OMP_PROC_BIND=close OMP_PLACES=cores, 28 threads) and report the wait policy; the first objective is sensitive to how busy the cores were just before it.mospTest sosp(148 cases) is not in the committed fixtures; the parity step exports it.
Open items#
End-to-end time on large graphs is dominated by text parsing done sequentially (graph and 2K tree files); parallel parsing is planned with the shared parser (M2).
Each sssp result owns a workspace (about 38 bytes per vertex); K results on one graph use K workspaces, where MOSP shares one.
mosp(0.2) should share.OpenMP race checking (Archer) is not set up; see the tsan row above.
Step 4: parity harness and the M1a parity proof (2026-09-27)#
Done#
parity/references.toml: MOSP-OpenMPc352151(baseline-2026-09 =7284f50) and MOSP-CUDAe220ee2(baseline-2026-09 =ac29545), full SHAs, paths, build and test commands, run environment, external inputs, toolchains.parity/build_reference.sh:git archiveinto$DYNG_SCRATCH/ref/<name>@<commit7>/{unpatched, patched}, verification of every tracked file against the archive, build with the original Makefile (MOSP-CUDA withNVCC=/usr/local/cuda-13.1/bin/nvcc), additive export patch on the patched copy only, archive-build check report, and a before/after check that the original’s HEAD and status did not change. Idempotent (a second run takes about 1.5 s).--testran both originals’ ownmake testin the unpatched copies: all passed.Export patches (
parity/export_patches/<repo>/build.sh) build the exporters ofparity/exporters/mosp/(moved fromparity/fixtures/) against the copies’ sources; the graph/io exporter also builds against MOSP-CUDA with nvcc. The two fixture scripts now use the patched copy; they regenerate identical fixtures, and their ad-hoc archives (runs/graph-io-fixtures,runs/sssp-fixtures, 24 MB) were deleted.parity/export_goldens.py: the sssp golden corpus, 388 cases in 9 groups (10 testcases, 3 count-to-infinity regressions, the thesis example, 4 ESCHER/ties cases, 17 graph/io fixture inputs, the 148mospTest sospcases, 2 large-weight cases, 3 packing boundaries, 200 stress cases), 144 MB under$DYNG_SCRATCH/goldens/sssp, with the original implementations cross-checked before each case is written.--twicere-exported from a fresh archive copy: identical manifest.parity/goldens.toml(52 KB) is the committed manifest.parity/compare.pyand the CTest testparity.sssp.mosp_openmp_c352151(labelparity,paritypreset; skipped without goldens). Result: 388/388 byte-identical on sequential and OpenMP 1/4/16 threads, in theparityanddevpresets (compute, update, updated CSR,invalidated). A mutation (OpenMP invalidation reduced to the roots) gives 266 mismatches; a changed golden byte is detected.One-off cross-check: MOSP-CUDA@e220ee2 == MOSP-OpenMP@c352151 on all 388 cases (trees, combined graph and MOSP costs, updated CSR and weight-increase bits, counters).
parity/perf_ab.pyand the OpenMP A/B on roadNet-CA (safe 50K, unsafe 50K, local 10K with 160 hops, seed 777, generated by the original’s ownmospPrepas itsbench/prepare.shdoes; 28 threads pinned; 9 alternating rounds under the perf lock): SOSP compute summed over the objectives 0.92 / 0.95 / 0.87x of the original (withdrawn in Step 6: that reading left the moved first-touch cost out and used the sum instead of the per-objective gate; see Step 6 andparity/results/M1a.md).ADR 0013 (draft),
parity/README.md, the timed-region map extended (workspace finding, draft MOSP-CUDA section for M1b), CHANGELOG, README.
Found and fixed#
Concurrent reading did not happen under thread pinning. With
OMP_PROC_BINDset, libgomp pins the initial thread to one core at start-up, andstd::thread/std::asyncworkers inherit that one-core mask: reading the batch and the six tree files “concurrently” withstd::asynctook as long as reading them one after the other (1.19 s vs 1.27 s). The original’srunConcurrentlyruns its jobs on OpenMP threads, which are placed on distinct cores. dynG now hasdetail::run_concurrently(OpenMP region when enabled,std::asyncotherwise), used byio::read_csr_triplet(RowPtr, ColInd and Values in parallel; any problem re-runs the strict sequential readers so the first error is unchanged), anddyng-compat-mospreads, builds its K results and writes the same way. Input phase on roadNet-CA: 1.27 s -> 0.63 s; tree import 150 -> 66 ms; end to end 1.60 s -> 0.87 s.
Deviations from the plan (pragmatic choices, same intent)#
Plan |
What was done |
Why |
|---|---|---|
|
|
the MOSP exports need no change to any original file; an added program is the most additive patch possible, and |
|
|
the author’s work area (Appendix E) |
|
one digest per case (SHA-256 of the case’s sorted per-file lines) plus the SHA-256 of the full per-file |
8,620 per-file lines would make the committed file about 1 MB; the two-level scheme verifies the same bytes |
goldens as a release asset ( |
goldens only in |
no remote before M4; the asset and fetch script come when CI needs the goldens |
golden replay under |
|
the replay drives the compat tool and the goldens outside the tree; a script is simpler than a C++ test and also replays another original ( |
parity certificate |
|
the certificate per release starts with 0.1; the M1a record has the same content |
stress tests: 100 |
|
with the same seed the two programs draw the same 100 cases; seed 2 gives 100 more |
packing boundary n = 2^17 - 1 |
the MOSP-CUDA construction (pull and push) and the MOSP-OpenMP one (n = 2^16 + 1), written by the export script |
|
perf inputs from |
the prepared CSR is reused (symlink); |
the dataset’s trees and batches were made by an older MOSP-CUDA |
— |
|
the end-to-end gap on roadNet-CA (see “Found and fixed”) |
Performance findings to carry into M1b#
Per-objective gate vs workspace sharing. Objectives 1 and 2 of the 50K batches are 3-6 % slower than the original, objective 0 15-20 % faster; the sum is 5-8 % faster. MOSP shares one
SospWorkspaceacross objectives; each dynG result owns one. A throw-away experiment with one shared workspace put objectives 1 and 2 at 0.97-1.03x. Decide in M1b (before the CUDA gate) whether results on one graph can share scratch (e.g. throughresourcesor an explicit workspace argument ofupdate_each);mosp(0.2) needs it anyway. (Corrected in Step 6: an earlier sentence here proposed reading the gate on the sum over the objectives until then; that would weaken the gate PLAN 6.4.2 defines and is withdrawn. The gate is per objective; ADR 0013 records the unmet gate as an M1b blocker.)End to end 1.27-1.37x (gated at 1.10x from M1b):
graph::from_csrbuilds a transposition of the loaded graph (213 ms on roadNet-CA) thatapplyrebuilds for the updated graph anyway, andfrom_arraysvalidates the trees (66 ms for three). Candidates: afrom_csrthat takes the arrays by value and builds the in-edges lazily, and a parallelvalidate_tree.The original’s own run-to-run spread on this shared machine is large (reading the CSR 230-380 ms, objective 0 22-28 %); use at least 9 rounds and report the spread.
OMP_WAIT_POLICYchanges the per-objective picture (both sides);perf_ab.py --envrecords such settings.
Notes for the next steps#
M1b: add a
mosp_cudadriver path tocompare.pyfor dynG’s CUDA backend (the compat tool gets--backend cuda), and a CUDA-events variant ofperf_ab.py(>= 20 runs for regions under 10 ms, GPU 0,CUDA_MODULE_LOADING=EAGER). The MOSP-CUDA reference copies are already built, and its timers are mapped (draft) inparity/timed_regions/sssp.toml.The golden set also holds MOSP’s combined graph and MOSP costs per case, for
mosp(0.2).Space in
$DYNG_SCRATCH: ref 56 MB, goldens 144 MB, bench inputs 160 MB (+ the datasets).
Open items#
The goldens are not yet published (release asset +
fetch_goldens.py, M4), so theparitylabel only runs whereexport_goldens.pyhas run.The per-objective OpenMP gate and the end-to-end gap above (M1b).
perf_ab.pymeasures the OpenMP backend only; the sequential backend has no original counterpart with timers (sequentialSOSPUpdateis a file-based function).
Step 5: close-out (2026-09-27)#
Done#
Doxygen conventions checked, not only Doxygen’s warnings.
ci/doxygen_coverage.pyreads Doxygen’s XML and requires what Doxygen does not enforce (PLAN Section 9.1): an@filebrief on every public header, a@briefon every public entity, every namespace-scope entity in a group (@ingroup), and@backends,@determinismand@paperon the algorithms’compute()andupdate().ci/docs.shruns it after Doxygen (WARN_AS_ERROR = FAIL_ON_WARNINGS,WARN_NO_PARAMDOC = YES). Both were checked to fail on a removed@ingroupand a removed@backends.MULTILINE_CPP_IS_BRIEF = YESmakes a multi-line///comment the brief, which is how the headers were written; one field (graph_properties::num_weights), whose///block Doxygen had merged into the///<comment of the field before it, was fixed. The CMake targetdocs-doxygenruns the same check on its build tree.ci/check.sh:--parity(orDYNG_CHECK_PARITY=1) configures and builds theparitypreset and runsctest -L parity, the golden replay; with the flag, missing goldens are an error, not a skip. A newtidystep runs clang-tidy with only the naming rules of ADR 0004 on the library sources, with warnings as errors (checked to fail on a CamelCase local). Steps now run in the order format, build, tidy, REUSE, docs, pre-commit, parity;--helplists them.cpu.ymlandlint.ymlname the steps they mirror.The ported library code’s upper-case
Klocals becamenum_objectives(the only naming violations the tidy step found incpp/src).README: the quickstart now covers build, tests, running the example (with its expected output and a
cmpagainst the original’s file) and running the parity check; the status table lists the graph container and I/O.Community files ahead of M4:
CODE_OF_CONDUCT.md(Contributor Covenant 3.0, CC BY-SA 4.0, with the O13 contact),SECURITY.md,SUPPORT.md;GOVERNANCE.mdrecords O13 and the account steps the author has done. The repository is to be public from the start (Appendix E), and PLAN Section 10.1 requires the Code of Conduct before that.ADRs: 0004 notes how the naming rules are enforced; 0006 records the measured cost of per-result workspaces and the open M1b question; 0013 names
ci/check.sh --parity. 0001, 0002, 0010 and 0014 were re-read against the repository and needed no change (the Code of Conduct is a third-party text under CC BY-SA 4.0, declared inREUSE.toml; everything dynG authors stays Apache-2.0 as ADR 0002 decides).
Deviations from the plan (pragmatic choices, same intent)#
Plan |
What was done |
Why |
|---|---|---|
Section 9.1: the conventions are “checked in review” |
|
a script cannot know every exception a function throws; everything else is mechanical |
|
|
bibtex is not in the environment (Step 3); M5 adds the Sphinx site and can add bibtex |
deleted special members |
not required to have a |
|
clang-tidy in CI (Section 8.7) |
naming rules only, locally, on |
the conda clang-tidy lacks clang’s resource headers (GCC’s builtin include directory stands in); the other checks and the hosted job come with M4 |
Code of Conduct, SECURITY, SUPPORT in M4 |
added now |
the repository is public from its first push (Appendix E) |
Step 6: fixes after the independent review (2026-09-27)#
Independent reviewers checked M1a at d150288 (four lenses: parity correctness, API
conventions, build/CI hygiene, the performance harness); a skeptic verified each finding. All 25
confirmed findings were fixed in small commits (b32532e .. the commit that adds this section),
except the root cause of one performance finding, which is an M1b blocker (below). Every fix was
followed by the affected tests; the golden replay and the performance A/B were re-run at the end.
Correctness and API#
Finding |
Fix |
|---|---|
The sequential backend’s tie rule differed from |
One rule for every backend, |
A result of graph A was accepted by |
The graph state also carries a process-wide unique identifier (renewed by construction and by every batch, copied by |
A poisoned result still served its arrays; the sequential engine did not detect a parent cycle |
|
|
Translated to |
A moved-from |
Moves share the handle like copies ( |
The log sink ran with the logging mutex held (a logging sink deadlocked) |
The sink is copied under the lock and called after it ( |
|
Documented what it does in M1a: it sizes the current storage only; the host apply (a straight port of |
Stricter batch parsing changed MOSP’s accept/reject decisions; this file said “same decisions” |
|
Provenance headers missing in |
Added (also |
|
|
Build, CI and repository hygiene#
Finding |
Fix |
|---|---|
|
The job downloads the official Doxygen 1.18.0 binary (SHA-256 checked) and |
The dev preset ( |
Structured-binding captures and empty variadic |
This file and the docs contradicted the GitHub / PyPI state |
Corrected here (remote, hosted runs, the published 0.0.1); |
Personal paths and the host name in |
|
|
Every test runs with |
|
Pinned Doxygen and CMake ( |
CITATION abstract claimed Python bindings |
Corrected ( |
No Python lint or CI test for 1.5k lines of harness code |
ruff check + format hooks, |
Performance harness#
Finding |
Fix |
|---|---|
The workspace pre-touch moved first-touch page faults out of the gated region and inflated “SOSP at parity or faster” |
The pre-touch is its own profiler stage ( |
The |
The port side adds |
The per-objective 1.05x gate was proposed to be read on the sum |
Withdrawn; the sum is reported as |
|
|
|
Unknown groups and empty or malformed configurations are errors; an empty case list fails ( |
|
It detects the |
Weak correctness and provenance guards in |
Invalidated counters compared in every round (non-zero exit on a difference), parity-preset build required and recorded, the unpatched copy rebuilt and verified before timing, regions under 10 ms need >= 20 runs (default 21) or are marked provisional ( |
|
Appended logs; a build fingerprint in |
Deviations from the plan (pragmatic choices, same intent)#
Plan |
What was done |
Why |
|---|---|---|
sequential backend = |
its Step 2 uses |
one tree on every backend and equal to |
PLAN 5.1/5.2: the version “+1 per applied batch” is what results check |
the version keeps that meaning; results also record a process-wide unique graph-state identifier |
two graphs (or a reassigned graph variable) have equal versions; the plan’s intent (D6, R6) is that stale results are detected (ADR 0006) |
Section 4.2: |
no conda compiler; the system GCC 12.2 builds everything; Doxygen and CMake pinned |
the parity preset must use the compiler the originals were built with (the system |
I9: |
|
I9 exempts the straight port of |
goldens: canonical initial trees (mospPrep init) |
a |
the canonical corpus could not detect a tie-rule difference; the perturbation is an input, the expected outputs still come from the original’s |
|
new steps |
the provenance rule of PLAN 3.4 and the Python harness had no mechanical check |
Process notes#
Commit
c812269put an unescaped@c352151into a Doxygen comment, which brokeci/docs.shuntild253061fixed it; the docs step had not been run before that commit. Run the whole gate, not only the tests of the changed module, before each commit.The golden replays, the MOSP-CUDA cross-check and both A/B records were re-run after the fixes; the second A/B record was first marked
+dirtyonly because the first run had just written its JSON (the JSON explains it;perf_ab.pynow ignores its own records in the dirty check).The two new Clang CI jobs and the Doxygen 1.18.0 job run for the first time on the next push (the orchestrator pushes). Clang 20 and the 1.18.0 binary were checked locally.
Open items (added in Step 6)#
M1b blocker: results on one graph must share scratch memory (or an equivalent), and the per-objective OpenMP gate must then be re-measured with the first-touch cost counted (ADR 0013).
Confirm on GitHub that
lint(Doxygen 1.18.0, theharnessjob) and the new Clang jobs ofcpuare green after the next push.
Milestone summary#
M1a was carried out in five implementation steps, all on 2026-09-27: bootstrap (Step 1), graph
container and I/O (Step 2), sssp on the CPU backends (Step 3), the parity harness (Step 4),
close-out (Step 5), and the fixes after the independent review (Step 6, 25 commits). The
repository has 67 commits on main (pushed to dyng-dev/dyng by the orchestrator, never by
an implementation step; origin/main was at 15a6051 when Step 6 started) and is small (about 1 MB of tracked files, of
which 121 KB are the committed test fixtures).
Area |
Size (lines, tracked) |
|---|---|
Library: public headers and sources ( |
9,900 |
Tests ( |
4,000 |
Parity harness, compat driver, CI and scripts |
3,800 |
Documentation (Markdown, ADRs, algorithm and API pages) |
1,900 |
Acceptance record#
# |
Criterion |
Evidence |
Status |
|---|---|---|---|
1 |
A fresh clone configures, builds ( |
Step 6 run in a fresh |
met |
2 |
sssp sequential and OpenMP byte-identical to MOSP-OpenMP c352151 on the golden corpus; randomized cross-checks against |
495/495 cases (Step 6 added 107 cases with non-canonical initial trees, on which the sequential backend of Steps 3-5 would have failed); before that 388/388 cases (the 10 generateTestCases cases, the 3 count-to-infinity regressions incl. n = 6 seeds 621705/250813 with d(1) = 90, the ESCHER 4-vertex case, ties, delete-all, the 148 |
met |
3 |
The updated CSR under |
part of every golden case (495/495) and of the 27 committed apply fixtures (CSR, transposition and weight-increase flags) |
met |
4 |
Parity harness from scratch; the one-off MOSP-CUDA == MOSP-OpenMP cross-check |
|
met |
5 |
OpenMP A/B on roadNet-CA recorded; large regressions explained |
|
met (recorded and explained; the gates bind from M1b, where the per-objective gate is an open blocker) |
6 |
Repository hygiene |
README, LICENSE, NOTICE, CITATION.cff (valid CFF 1.2.0), AUTHORS, CHANGELOG, GOVERNANCE, CODE_OF_CONDUCT, SECURITY, SUPPORT, |
met (lint green to be confirmed after the next push) |
7 |
This retrospective with lessons, deviations and a re-estimate |
this file |
met |
Final verification (Step 6)#
All in a fresh git clone of the code as committed (acc7a5b, then the sanitizer-skip commit
tests: skip the 8 TiB allocation checks under sanitizers for the asan/tsan rows; this
retrospective and the CHANGELOG were added after it and re-checked with the lint steps):
Command |
Result |
|---|---|
|
all steps passed: clang-format; |
|
196/196 (skips by design: impossible allocations under sanitizers, “OpenMP not built” in an OpenMP build) |
|
173/173 |
OpenMP off, Release, |
173/173 |
Clang 20 ( |
builds warning-free, 173/173 |
|
wheel and sdist of |
|
valid for CFF 1.2.0 |
Measured parity and performance (from Step 4)#
Parity: byte-identical on 495/495 golden cases (388 canonical, 107 with non-canonical initial trees) for every backend and thread count tried;
invalidatedequal to the original’s counter everywhere. The two originals agree with each other on the whole corpus, so a CUDA mismatch in M1b will belong to the port.Performance (OpenMP, roadNet-CA, 28 threads pinned, 21 alternating rounds, corrected region map): per objective, objective 0 is 1.07-1.09x on the 50K batches and 1.44x on the local batch with its moved first-touch cost counted (0.79-0.81x as measured), objectives 1 and 2 0.81-1.04x; apply 0.86-0.94x; end to end 1.30-1.41x (the original’s combined graph subtracted). The first record’s “SOSP at parity or faster” and “apply 23-33 % faster” were withdrawn. Details and spreads in
parity/results/M1a.md.
Deviations, consolidated#
Every deviation is in the table of the step that made it; the ones that matter beyond M1a:
API additions:
dyng::update_each()(run-time number of results),apply_summary:: ignored_insertions,batch_semanticsdefaulton_self_loop = keep(= MOSP),result:: get_options()notnoexcept(ADRs 0006, 0010).Semantics made explicit:
update_stats::affecteddefined as vertices whose distance or parent changed; the sequential backend uses the graph’s weight-increase classification soinvalidatedis identical across backends; stricter parsers than MOSP (same decisions on every well-formed file).Build and test layout: one test executable per module; the Doxygen config is
docs/Doxyfiledriven byci/docs.sh;DYNG_ENABLE_CUDAfails until M1b; thetsanpreset runs without OpenMP (libgomp is not instrumented).Parity layout: additive exporter programs instead of
*.patchfiles; goldens only in$DYNG_SCRATCHwith a two-level manifest; the golden replay as a CTest-registered script;parity/results/M1a.mdinstead ofbenchmarks/results/<version>/parity.json(ADR 0013).Performance changes outside the straight port: a parallel deterministic transposition, workspace pages touched at result creation (a moved cost, counted in the A/B since Step 6), concurrent reading on OpenMP threads.
Ahead of plan: community files of M4 (Code of Conduct, SECURITY, SUPPORT), the Doxygen convention check, the clang-tidy naming step.
After the review (Step 6): one tie rule on every backend (
sospUpdateCpu’s; the sequential backend no longer equalssequentialSOSPUpdateon non-canonical trees); results record the graph-state identity; MOSP’s lenient batch parsing on the compatibility path; no conda compiler inenvironment.yml(ADRs 0006, 0010, 0013).
Lessons#
Run the gate in a fresh clone before calling a step done. A repository-wide
*.mtxignore rule hid two fixture inputs (Step 2); only the fresh clone showed it.Cross-check the originals before blaming the port. Four implementations inside MOSP-OpenMP and the whole MOSP-CUDA build agree byte for byte on the corpus; that turned every later mismatch into a dynG bug by construction, and it will do the same for M1b.
Regenerate inputs with the pinned original’s own tools. The prepared trees under
dyng-work/datasetswere made by an older tool and are not canonical; they would have produced false mismatches in any comparison against Dijkstra.The performance differences were in the surroundings, not in the kernels. Idle cores before the first objective (sequential transposition), first-touch page faults inside the timed region, per-result workspaces, and
std::asyncthreads inheriting the one-core mask of the pinned initial thread. Each was found only because the timed-region map existed before the port and the A/B alternated runs on the same inputs. Keep that order for every port.A shared machine needs many rounds and reported spreads. The original’s own run-to-run spread was 20-35 % on some regions at load average 7-11; nine alternating rounds and the spread in the table made the results readable.
Doxygen’s warnings are not the documentation rules. Missing groups and missing briefs pass Doxygen; merged
///<and///blocks silently move text. A small XML check closes it.Environment gaps are real work items. clang-tidy without resource headers, no bibtex, no Archer for OpenMP race checks, no actionlint: each needed a workaround or a deferral, and each is recorded here instead of silently skipped.
Straight ports first, then measured changes. Every change beyond the straight port (parallel transposition, concurrent reading, touched workspaces) came with a measurement and left parity intact, which made the reviews of Steps 3 and 4 easy.
A corpus only tests the inputs it contains (Step 6). All 388 golden initial trees were canonical, so a tie-rule difference between the two backends, visible on the real dataset trees, passed every check. Perturb the inputs the originals accept but the tools happen not to produce, and state the conditions of every postcondition.
A timed region is defined by what it contains on both sides, not by its name (Step 6). Moving work out of the port’s region, or leaving work inside the original’s, changed the headline ratios by 10-30 %. The region map must list every piece of work of the original’s timer, the tool must load the map, and a moved cost must be counted where the original pays it.
Local green is not hosted green (Step 6). The pushed
mainhad a redlintjob for a Doxygen version difference that the local gate could not show; pin the tool versions of the local gate and of CI to the same values.
Re-estimate of the roadmap#
Measured: the plan estimated M1a at 2-3 working weeks. With the AI assistant doing the coding, all of M1a (repository, core, graph, I/O, two sssp backends, the parity harness with a 388-case corpus, the performance A/B and the documents) was implemented in five sessions on one day. The author’s review of M1a has not happened yet and is not included.
Why the remaining milestones will not scale by the same factor:
M1a was the most mechanical milestone: CPU only, one original with good tests, no API design decision that had to be defended across algorithms.
Most of M1a’s steps went into work that grows with the remaining milestones rather than with their lines of code: the parity harness, the performance investigations and the documentation (Steps 1, 4 and 5 and much of Step 3; the two ported engines are about 900 lines). M1b and every later port add GPU performance gates on a shared, noisy machine (each gate iteration costs at least 9-20 alternating rounds).
M3 (framework extraction and the API freeze) and M5 (Python wheel, Sphinx) are design and packaging work with an author sign-off at the end; M4 continues the hosted CI work, whose first runs (2026-09-27:
cpugreen,lintred on the Doxygen version,releasepublished 0.0.1 on its second run) already needed a fix.The independent review of M1a found 25 real defects (Step 6) in a milestone that had passed its own gate; every later milestone needs such a review and a fix step before it closes.
Author review and account actions are calendar-time bottlenecks that the AI cannot shorten.
The re-estimate therefore applies a speed-up of about 3x to the plan’s figures for the remaining work (not the 10x or more that M1a alone would suggest), and gives calendar time separately, assuming the author reviews at each gate within a few days.
Milestone |
Plan (working weeks) |
Re-estimate: focused AI effort |
Calendar incl. author gates |
Main risk |
|---|---|---|---|---|
M1b CUDA |
2 |
4-6 days (incl. a review-and-fix step) |
1.5-2 weeks |
blocker: the per-objective OpenMP gate (1.05x) needs results that share scratch memory; end to end (1.10x) needs a lazy transposition in |
M2 |
2-3 |
4-6 days |
1-1.5 weeks (parallel with M1b) |
the |
M3 framework + conformance kit + 0.1 API freeze |
2-3 |
5-8 days |
2-3 weeks |
design judgment; parity and performance re-run per commit; API review sign-off by the author |
M4 GitHub repository and infrastructure |
1-2 |
1-3 days |
3-5 days (can start now: org and repository exist) |
the Clang and Doxygen 1.18.0 jobs added in Step 6 run for the first time; DCO app and branch protection (author) |
M5 Python CPU wheel, CLI, docs |
2-3 |
5-8 days |
2-3 weeks |
nanobind + scikit-build-core, stubs, Sphinx |
0.1.0 |
12-17 (from M0) |
about 4-6 weeks of focused work |
about 7-10 weeks |
|
0.1.x (M6: CUDA plugin wheels, tutorials, RTD/Zenodo, fuzzers, mutations) |
4-6 |
1.5-2 weeks |
3-4 weeks |
GPU runner decision (O11); wheel sizes |
0.2.0 (M7 |
8-12 |
3-4 weeks |
6-8 weeks |
the staged CBST merge (trace replay for both originals’ policies); memory within 1.05x; performance on the 2M-hyperedge suites |
0.3.0 (M10 |
8-12 |
3-4 weeks |
6-8 weeks |
DynLP’s calibrated float tolerances and slotted/slack layouts; conda-forge review time |
Up to 0.3.0 |
about 32-47 |
about 12-16 weeks |
about 22-30 weeks |
Recommended order now: start M4 in parallel with M1b (the organization, repository and trusted publishers exist, and the first hosted CI run will surface workflow problems early); decide the workspace-sharing question at the start of M1b, before the CUDA performance gate; run M2 in parallel with M1b where GPU time allows. This estimate is revisited in the M1b and M3 retrospectives.
Open items carried forward#
For M1b:
Blocker: workspace sharing among results on one graph. With the moved first-touch cost counted, objective 0 is 1.07-1.09x (50K) and objectives 1-2 are close to the limit; a throw-away shared workspace put objectives 1-2 at 0.97-1.03x in Step 4. Re-measure per objective with the first-touch cost counted (ADR 0013).
End to end 1.30-1.41x of the original (gate 1.10x): a
from_csrthat builds the in-edges lazily or takes arrays by value, and a parallelvalidate_tree.compare.py/perf_ab.pypaths for the CUDA backend (compat--backend cuda, CUDA-event timing, GPU 0 for performance).
For M4 (hosted CI and release):
Confirm the hosted runs after the next push:
lint(now Doxygen 1.18.0 and theharnessjob; it was red at15a6051on Ubuntu’s Doxygen 1.9.8) and the Clang 17/18 jobs ofcpu. actionlint is not available locally.The goldens are not published (release asset +
fetch_goldens.py);ctest -L parityruns only whereexport_goldens.pyhas run.clang-tidy (all configured checks) in a hosted job; OpenMP race checking with Archer.
For the author (no action blocks M1b):
Done: tag
v0.0.1was pushed anddyng0.0.1 is on PyPI and TestPyPI (2026-09-27 16:26 UTC; the firstreleaserun failed in the TestPyPI publish step, the second published both). Still open: enable private vulnerability reporting (Settings -> Security), asSECURITY.mdpromises.Confirm the facts listed under Step 1 “Open items for the author” (placeholder-identity commits, funding lines, the ESCHER IPDPS 2026 title and author list, the TruCy paper status, S M Ferdous’s affiliation).
Review this milestone (ADRs 0006, 0010 and 0013 are drafts awaiting acceptance at M3 / 0.1).