Retrospective: M2b (merge of M2a, cycle_count on CUDA)#
Status: complete (2026-09-28) on the branch m2b-cycle-cuda (from main at eda8b8b,
INT1), pending the independent review and the orchestrator’s pull request into main. M2b
merges the accepted M2a branch m2-cycle (cycle_count on the sequential and OpenMP backends)
and ports cycle_count to CUDA against CycleEnumeration-GPU@0a976ad. Steps 1-3 each appended
their section; step 4 (close-out) added the milestone summary, the M2 summary, the acceptance
record and the re-estimate of the roadmap.
Step 1: merge of M2a (merge-m2a)#
Commit |
What |
|---|---|
|
|
|
the API page of the |
|
|
|
the repository state of 2026-09-28: the |
|
the re-verification records ( |
Conflict resolution.
cpp/include/dyng/graph/graph.hpp: the class comment lists M2a’sunweightedinstantiations and M1b’s int32 defaultedge_twith checked construction (ADR 0009);from_edges()andfrom_csr()document both sides’ errors (M1b’s copy-policy andcapacity_error, M2a’s unusable batch semantics).cpp/src/graph/apply_host.cpp: both sides had changed the validation at the top ofapply_batch_host(). M1b had replaced the per-array host checks withexpect_host_batch(); M2a had moved all checks intovalidate_batch_shape()(apply_common.hpp), shared with the set apply, which also checks host accessibility.validate_batch_shape()stays (it runs before the set/multigraph dispatch, so both applies are checked); the checks are the same. M1b’s file-localexpect_hostis dropped in favour of M2a’sdetail::expect_host(two definitions would be ambiguous).checked_edge_count: M1b moved it fromapply_host.cpptoapply_host.hpp, M2a toapply_common.hpp; both headers are included together, so it now exists once (apply_host.hpp, with M1b’s ADR 0009 comment). This was not a textual conflict; the build would have failed with a redefinition.cpp/src/graph/graph.cpp: both includes (core/staging.hpp,graph/apply_common.hpp).graph::applystages device batches on the host (M1b) and then reaches the same host apply, which dispatches to the set apply underbatch_semantics::as_sets(M2a).cpp/tests/CMakeLists.txt: M1b’s framework tests (thread_list,list_gather) and M2a’s generator and CycleEnum parity tests.parity/export_goldens.py(global REFERENCE, COMMITfirst, then thecycle_countdispatch),parity/tests/test_harness.py(both test sets),README.md(both rows and paragraphs).Auto-merged but checked:
device_graph.cuinstantiates the unweighted device graph (M2a), the mutation-test libraries linkCUDA::cudartin a CUDA build (M2a anticipated it), and the M1b placement checks reject a CUDA graph incycle_count(CycleCountBackend.PlacementIsChecked).
ADR numbering. M2a wrote no ADR of its own: its decisions are an amendment of ADR 0010 (set semantics and the unweighted graph), which keeps its number. Nothing had to be renumbered from 0020; the next new ADR of M2b takes 0020.
Re-verification (parity/results/M2b.md, section 1): ci/check.sh --parity,
ci/gpu_local.sh and ci/docs.sh pass; sssp 495 / 495 byte-identical on sequential, OpenMP
(1-28 threads, int32 and int64 edge offsets) and CUDA (both edge types); cycle_count 24 cases x
3 configurations equal to the goldens; the sssp OpenMP and CUDA gates (locked clocks) on
roadNet-CA and road_usa and the cycle_count OpenMP update gate on DD and GitHub read as before
the merge. No regression was found, so no fix was needed.
Deviation. The sssp performance spot check on road_usa ran 11 rounds per batch instead of
M1b’s 21 (every gated region there is >= 10 ms, where PLAN 8.6 asks for >= 5 runs); roadNet-CA,
with regions under 10 ms, ran 21. The performance records carry +dirty because CHANGELOG.md
was edited during the runs (documentation only; the binaries were built from fbeb58a).
Step 2: cycle_count on CUDA (cycle-cuda)#
Commit |
What |
|---|---|
|
graph and framework: Step 0 of set semantics once per update ( |
|
|
|
the exporter’s CUDA build and the CUDA fixtures ( |
|
the shared |
|
|
|
the golden set |
|
ADR 0020, the algorithm page, statuses, CHANGELOG, |
|
|
|
|
What was ported, and how straight. The kernels are CycleEnumeration-GPU’s, line for line:
count_roots_kernel, count_roots_queue_kernel, forward_rows_kernel, fill_edge_items_kernel,
target_degree_kernel, count_edge_items_kernel, count_two_hop_items_kernel,
extend_prefix / find_edge / lower_bound_u32 / ThreadHistogram / dispatch_capacity,
mark_owners_kernel, item_counts_kernel, count_owned_cycles_kernel / extend_owned_path /
closes_owned, change_rows_kernel, next_degree_kernel, build_next_rows_kernel, and the host
logic around them (resolve_work_items with its kDenseAverageDegree, plan_work_queue_launch,
effective_length, the grid rules). The mechanical changes: names and namespaces, the row offsets
as a template parameter (std::make_unsigned_t<edge_t>: with int32_t offsets the kernels are the
original’s 32-bit ones; cuobjdump gives the same register counts and stack sizes, e.g. 19
registers for count_edge_items_kernel<4> and 22 for count_owned_cycles_kernel<4> on both
sides), dyng buffers leased from the workspace pool on the stream of resources instead of
cudaMalloc on the legacy stream, scalar read-backs through pinned memory, errors as dyng
exceptions.
Design decisions (ADR 0020 where they touch the graph):
The graph is resident. The original uploads G_t in every update and builds G_{t+1} on the device only for its insert phase; dynG must also produce G_{t+1} (the update applies the batch), and the M2a host set apply alone costs more than the original’s whole DD update (6 ms against 4.4 ms). So
graph_access::applymerges the batch on the device for a resident graph underas_setswithout weight columns, and the host CSR becomes a lazily downloaded copy.build_next_rows_kernellives in the graph module (it is the container’s device apply, PLAN 6.4.1), and itsnext_ownerarray is kept with the device state (insertion_ids) for the cycle_count insert phase. The delete-phase owner array is computed by cycle_count and, a second time, by the device apply for its deletion marks (a memset and one binary search per deletion: tens of microseconds on DD), so the container does not depend on the algorithm.Step 0 once. Under set semantics the framework normalizes the batch before the participants and hands the result to the commit; cycle_count copies its change lists from it. This removes M2a’s second normalization inside the commit on every backend.
The 64-vertex bound follows the original: the effective bound max(min(k, n), 2) must be <= 64, so a large k (or no bound) is accepted on a graph of at most 64 vertices; an update checks the bound with every vertex its batch names before anything changes.
engine::operatorsis rejected withnot_supported_erroron cuda, as for sssp in 0.1; the original’s--cuda-schedulerand--cuda-work-itemsare options (scheduler,work_items); its environment tuning variables are not ported (the defaults are fixed).dyng-compat-cycle-enumusesint32_tedge offsets on cuda by default (the original’s 32-bit device CSR;--edge-type int64is replayed as well) and has--scope original|resident: the original scope moves the graph to a fresh copy without a device copy before the timed update (g.clone(res)keeps the state, so the result still matches), so the update uploads G_t inside the timed call as the original does.
Deviations from the plan, recorded here.
PLAN 6.4.3 names
work_queue.cuh; the launch planning and the choice of work items are host-only code, so the file iswork_queue.hpp(unit tested in the CPU suite as the original tests them without a device).framework/work_items.cuhandoperators/expand_work_itemsare not created: the work items have one user (rule of two, PLAN 4.5.3); M3 extracts them.The COLLAB update (k = 4) is part of the CUDA golden set but replayed on the cuda configurations only: its prior is the k = 4 static count of COLLAB (199.7 billion cycles; 6.5 s on the original’s CUDA backend), far too long for a replay on the host backends.
The CUDA fixtures and goldens are new files and a new set next to the CPU ones (the M2a corpus and fixtures are unchanged, byte for byte); every CUDA histogram of the original was checked equal to its sequential or OpenMP histogram of the same case when it was written.
Found on the way. A first version of the randomized CUDA tests drew graphs from the shapes of the original’s CPU tests (up to 60 vertices at edge probability 0.7, bounds up to 8): the sequential reference recount of such a graph runs for hours. The CUDA suites use sparse shapes (or at most 30 vertices) and bounds up to 7; dense graphs stay in the small-graph suites.
Open items after this step. The performance gates of criterion 4 (the harness is in place:
parity/cycle_count_perf.py run --backend cuda), the fresh-clone runs of the three gate
scripts, and a device Step 0 (sorting and classifying a batch on the device, so a chain of
updates never downloads G_t) as future work beyond the port.
Step 3: parity and performance of the CUDA backend (cuda-parity-perf)#
Commit |
What |
|---|---|
|
graph: Step 0 of set semantics sorts with |
|
tools: the resident count scope uploads the graph with an untimed 2-cycle count instead of a full count |
|
parity: |
|
parity: the CUDA record keeps the original’s monitor window apart from the port’s (they were merged under one key) and adds a per-side GPU clock summary |
|
cycle_count, graph: the update returns the static-count work items when it begins, and the deletion marks of G_t are computed once per update and shared with the device apply (ADR 0020, point 6) |
|
parity: |
|
|
|
the certificate names the commit the gate scripts passed on |
Done. The CUDA gate of acceptance criterion 4 in full: ten cases, both scopes, the clocks
locked for the whole A/B, 21 rounds (11 for the COLLAB update, a region of about 200 ms), plus the
default-clock readings (ungated), the register, stack and occupancy table of all 33 kernels, and
the device memory of every gate case. Parity replayed on the final code: the CUDA set on cuda,
cuda:resident and cuda:int64 (72 / 72), on openmp:56 (23 / 23) and the CPU corpus with --full
(72 / 72). Every gated reading is within its gate (the COLLAB update’s at the base lock, which
since the review counts only once the author accepts ADR 0021); the summary is
parity/results/M2b.md section 6.
What the first gate campaign found, and what changed (numbers: parity/results/M2b.md
section 4.4):
The resident static kernel read 1.06-1.09x on GitHub, Twitch and COLLAB while the original scope read 0.97-1.00x. The kernel is the same; the compat tool’s residency call was a full count right before the timed one, and a counting kernel that follows another at once runs 5-12 % slower even at locked clocks (Nsight Systems: 56.0 then 61.3 ms in one process; a one- or two-second pause removes it). The original’s process never has that state before its kernel. The residency call is now a 2-cycle count (about a millisecond). No library change.
DD 100K+100K update read 1.13x (original scope). Step 0 on the host took 8.8 of the 12.2 ms:
std::stable_sortof 16-byte changes, 1.5x the original’sstd::sort. The unstable sort by (source, target, position) is equivalent (positions are unique); a sortedness check first skips the sort for lists already in order. 0.74x now; all replays unchanged.The COLLAB update could not be measured at the boost lock: its 6.5 s prior runs under the GPU’s power cap, the SM clock drops to 1350-1680 MHz in every process of both programs, and the monitor rejected 22 rounds in a row; the harness then exited without writing any record. The harness now records such a case as incomplete and continues; the case is read at the base lock, where the clocks hold (first written as an update of ADR 0018; since the review ADR 0021, Proposed, pending the author).
The record lost the original’s monitor window: in the CUDA mode the round record merged
{"original": window}with the port’s scope windows, and the scope “original” overwrote it. The rejection itself read the right window; only the JSON was wrong. Fixed before the campaign whose records are committed.Device memory: see below; fixed in
94523c5, after which the whole campaign was measured again on the final code (the committed records; thefe753d2campaign read the same within 0.02 in every ratio).
Device memory (PLAN 8.6 lists device memory <= 1.05x; M1b did not measure it). A first
measurement with nvidia-smi’s per-process totals showed the CUDA update far above the original
(COLLAB 25K+25K: 1040 against 592 MiB; Nsight Systems’ trace of the live allocations then gave
762 against 383 MiB). The causes were the prior’s static-count work items (about
300 MB of two-hop items on COLLAB k = 4), kept in the workspace through an update that never reads
them, and a second m-int array of deletion marks (cycle_count’s delete phase and the device apply
each marked the deleted positions). Both are fixed (94523c5). Nsight Systems’ memory trace (the
high-water mark of live cudaMalloc / cudaMallocAsync allocations) now gives the same peak for
both programs on all ten cases; nvidia-smi totals still differ by a fixed amount (the stream-ordered
pool reserves in 32 MB granules, and dynG’s process loads the kernels of every algorithm of the
library: about 34 MB on a three-edge graph).
Deviations from the plan, recorded here.
The COLLAB update is read at the base clock lock (1170 MHz), not at boost (ADR 0021, Proposed; written here as an ADR 0018 update and moved to ADR 0021 by the review): the GPU cannot hold the boost lock through its prior. Its default-clock reading (0.989x / 0.937x) is recorded as for every case.
The resident scope of the count task makes the graph resident with a 2-cycle count, not with a full count (
parity/timed_regions/cycle_count.toml).Device memory is compared as the peak of live allocations (Nsight Systems) and reported next to the process totals; PLAN 8.6 does not say which. It is within 1.05x on every case; M2b’s acceptance criteria do not list it.
Lessons.
On a GPU near its power cap, what ran just before a timed kernel matters even at locked clocks. A scope that adds GPU work before the timed region (here, to make the graph resident) must keep the GPU as idle as the original’s process leaves it.
A harness that gives up on one case must still write what it measured: one unmeasurable case cost a whole campaign. On a shared machine, a record per case also lets one case be repeated alone: another user’s GPU job hit two cases of the final campaign, which were repeated (
M2b-cuda-perf-cycle_count-repeat.json).Retained workspaces are right for steady-state throughput and wrong for peak memory when an operation keeps another operation’s scratch. Measuring device memory per gate case, not only time, found it.
Open items after this step. The fresh-clone runs of ci/check.sh --parity, ci/gpu_local.sh
and ci/docs.sh (criterion 1); the M2 summary and the re-estimate that close this page; the
author’s view on reading the COLLAB update at the base lock; the device Step 0 (ADR 0020) and the
one-time pinned-buffer allocation in the first static call (0.85 ms of DD’s reported
static_total) as future work.
Step 4: close-out (finish)#
Commit |
What |
|---|---|
|
the algorithm page: the CUDA work items of every scheduler and phase, determinism on cuda (claim order, unchecked device sums), the default-clock table, DD’s reported static total; the manifest lists the cuda backend and the original’s CUDA sources; the short plan marks M2 done |
(this commit) |
the final verification from a fresh clone, the gates on the final code ( |
Done. The documents listed by acceptance criterion 5 were checked against the code and the
records: docs/algorithms/cycle_count.md (work items, the 64-vertex bound, the resident graph
and the device apply, determinism, the performance tables in both scopes at locked and at
default clocks), the README, landing page and algorithm tables (sequential, OpenMP and CUDA), the
roadmap, the short plan, the CHANGELOG. The manifest of cycle_count still listed only the CPU
backends (“cuda: M2b”); it now lists cuda and the original’s CUDA sources (scripts/regen.py
reads it in M3). The three gate scripts ran from a fresh clone of the branch (below).
Deviations. None new.
Final verification (fresh git clone of the branch into the scratch area, heavy steps under
the shared perf lock, niced; the performance re-check under the exclusive lock):
Command |
Commit |
Result |
|---|---|---|
|
|
every step OK: clang-format; |
|
|
all ten steps passed: build, gpu 155 / 155, cpu 414 / 414, sssp CUDA goldens 495 / 495, cycle_count CUDA goldens 48 / 48, memcheck, synccheck (sssp and cycle_count), racecheck, clang-tidy on the CUDA branches |
|
|
OK |
the sssp CUDA gate (locked clocks) and OpenMP gate on roadNet-CA (21 rounds) and road_usa (11); the cycle_count OpenMP update gate on DD and GitHub (11) |
|
every gated reading within its gate: sssp CUDA per objective 0.976-1.001x, OpenMP 0.68-0.99x; cycle_count update DD 0.516x, GitHub 0.383x ( |
The commit after 6894e2a (this one) changes only documents and adds the performance records;
ci/docs.sh and the quick steps of ci/check.sh (format, REUSE, provenance, harness,
pre-commit) passed on it.
Step 5: the independent review and its fixes (review-fix)#
An independent review of 79a323e confirmed 19 findings (each verified by a second reviewer).
All are fixed on the branch; none is deferred. Commits in order:
Commit |
Findings fixed |
|---|---|
|
Self-loops under set semantics with |
|
The recorded mutations were only in the host code: |
|
Host-only kernel tests in the gpu executable: |
|
Staging errors named |
|
|
|
The original’s large-graph device test ( |
|
The DD 50K and 100K gates passed only through the sortedness skip (high): Step 0 now sorts with a least-significant-digit radix sort (stable on the batch order, so the lists are unchanged; its cost does not depend on the order of the batch) and the skip is gone. 100K records: shuffled 1.39 ms against the original’s |
|
Chained updates downloaded G_t every time (high): Step 0 of a graph whose host copy is stale runs against the device copy (the membership of each requested change in G_t tested on the device, the sort on the host); |
|
The download used the legacy default stream and pageable memory: it is ordered on the stream of the state’s buffers; a growing vector is released first and reserved with room (no regrowth copy). ADR 0020 point 8 |
|
No steady-state allocation test for the CUDA update; the device apply’s scratch (change rows, degrees, scan scratch) is pooled; |
|
The resident scope was measured on the first update only: |
|
The public graph documentation describes both apply paths |
|
ADR 0020 claimed an acceptance nobody gave and ADR 0018 was amended after its acceptance: ADR 0020 is Proposed (with the review’s points 7-10), ADR 0018 is back to its accepted text, and the base-lock rule is the new ADR 0021 (Proposed) |
|
README: the M2b certificate and the set-semantics graph row |
|
The radix sort still missed DD 100K (1.055x in the first campaign after the review, |
|
the gate records after the review ( |
|
the corpora replayed on the review-fixed code (72 / 72, 23 / 23, 72 / 72); the algorithm page, CHANGELOG, ADR 0020’s sort wording, |
|
two defects the first fresh-clone run found (a docstring over 100 columns; a seed-count assertion that failed under |
Deviations recorded by this step:
The short update regions are host clocks on both sides (PLAN 8.6 asks for CUDA events or nsys kernel sums over >= 20 runs below 10 ms). The original’s update region is host-side by construction (its
update_secondsis asteady_clockaroundupdate_histogram(), which runsprepare_batchandapply_histogram_deltaon the host) and has no device timer, so the gate compares the same host clock on both sides over 21 rounds, as M1b did for sssp (M1b retrospective). dynG’s CUDA-event time of the whole update is recorded next to it for the cases under 10 ms (update_device,parity/results/M2b.mdsection 8.2): it reads 0.14-0.20 ms above dynG’s host clock of the same case (its stage events synchronize the stream), so the host clock hides no device time. The certificate row no longer claims CUDA events for these regions.Step 0’s membership test runs on the device for a graph that a device apply produced (not in the original, which re-uploads G_t per call and tests on the host); the lists are the host path’s. The sort stays on the host.
The COLLAB update at the base lock is pending the author (ADR 0021): its reading is recorded, and acceptance criterion 4 is met for it only once the author accepts the rule.
Lesson. A performance fix must be judged on inputs the gate does not use. The sortedness skip made the gate pass on the one input order the generator produces; the radix sort passes on every order. The same goes for scopes: a resident scope measured once after the upload says nothing about the resident graph across batches, which is what it claims.
Step 6: the acceptance verification and its fixes (acceptance-fix 1)#
The independent acceptance verifier (fresh clone at 450a1c6) passed criteria 2, 3 and 5 on its
own evidence: its own archive build of 0a976ad, byte parity of the CUDA CLI on every required
dataset and delta, about 11,400 randomized checks with no failure, the sssp gates re-run on the
final code, and its own boost-lock A/B of GitHub static and the DD and GitHub 25K+25K updates
within the gates. It failed two things:
The status documents overclaimed (criterion 1): the short plan (status line and M2 row) said every CUDA gate was met, the roadmap said the CUDA backend is within the gates, and the algorithm page said “every gate is met in both scopes” right after its COLLAB caveat, while README and the certificate named the COLLAB update as pending. The certificate (section 4.5) and this retrospective (steps 3 and 4) still cited an “ADR 0018 update” that the review had moved to ADR 0021. Fixed: every status page names the exception, the short plan lists ADR 0021 among the open decisions, and the stale references point at ADR 0021.
The COLLAB 25K+25K update has no gate reading under the accepted protocol (criterion 4). The verifier’s own boost-lock run rejected every round (SM 1410-1620 MHz under the 230 W power cap during the 6.5 s prior), as ours did; its base-lock reading is 0.991x (original scope) / 0.946x (resident), ours 0.989x / 0.945x. This is not something the implementation can fix: ADR 0018’s rule (every busy sample of both programs at the boost lock) cannot be met by an unpatched original on this GPU (a different power limit, alternative 1 of ADR 0021, needs root and, as step 7 found, is not possible at all), and changing the rule, or reading only part of the process, would weaken an accepted check. It stays open for the author’s decision on ADR 0021; once accepted, the committed record
parity/results/M2b-review-cuda-perf-cycle_count-collab-update-base.jsonis the gate reading.
Step 7: the second acceptance verification (acceptance-fix 2)#
The second acceptance verifier (fresh clone at 6efc49f) passed criteria 1, 2, 3 and 5 on its own
evidence (ci/check.sh --parity, ci/docs.sh and ci/gpu_local.sh in the clone; sssp 495 / 495
on ten configurations and the sssp gates at the boost lock; cycle_count CPU parity 72 / 72 and the
OpenMP update gate; its own 0a976ad CUDA build byte-identical to dynG on 14 dataset cases; the
CUDA goldens 72 / 72; 4,193 randomized CUDA / OpenMP / sequential / oracle checks with no failure;
GitHub static and the DD and GitHub 25K+25K updates within the gates at the boost lock; 33 / 33
kernels with the original’s registers). It failed criterion 4 on one case only, the same one as
step 6:
The COLLAB 25K+25K update has no reading under ADR 0018’s accepted protocol. Its boost-lock run rejected every round (SM 1365-1605 MHz under the 230 W power cap during the 6.5 s prior, on both programs); its base-lock reading is 0.988x (original scope) / 0.945x (resident), end to end 0.992x. No code change can fix this: GPU 0’s power limit is already its maximum (
nvidia-smi -q -d POWER: current, default and maximum limit all 230 W), so even with root the limit cannot be raised;ncu --clock-controloffers no lock between base and boost; and the original computes the prior with its CUDA counter in the same process as the timed update (run_update()ofsrc/cli/cycle_enum_main.cpp), so the prior cannot be moved out of the monitored process without patching the original. Accepting ADR 0021 ourselves, or checking the lock only in part of the process, would change an accepted rule without the author. The case stays pending the author’s decision on ADR 0021; nothing in the code or the records was changed for it.
It also recorded a non-blocking observation: in its first ci/gpu_local.sh run, the racecheck
step failed once (compute-sanitizer racecheck on dyng_cycle_count_cuda_tests: “process didn’t
terminate successfully”, 0 hazards, no gtest failure printed; the process ended during the
CycleCountFixtures tests, the last output being the skip of
CycleCountRandom.UnboundedUpdateCostsAboutTheBoundedOne/cuda). It ran next to a CPU-heavy
compare.py cycle_count --full, and did not reproduce in the verifier’s six reruns or in its
second complete ci/gpu_local.sh. This step tried again at 6efc49f (GPU 1, DYNG_TEST_SEEDS=2,
as ci/gpu_local.sh runs it): the whole suite once (58 tests, 0 hazards, peak RSS 0.87 GB, so host
memory pressure is an unlikely cause on a 124 GB machine), and the Fixtures and Random suites
four times, the first two while the whole dev ctest suite ran next to them (-j 40, all passed)
and the last two while parity/compare.py cycle_count --full did (ALL EQUAL, 72 / 72). All five runs exited
0 with 0 hazards. The cause is not known; the kernel log of the shared machine is not readable
without the adm group, so an OOM kill or a signal could not be confirmed. It is carried forward
as open item 8.
Milestone summary (M2b)#
M2b was done in four steps on 2026-09-28 on the branch m2b-cycle-cuda (from main at
eda8b8b): the merge of M2a and its re-verification (step 1), the CUDA port (step 2), parity and
the CUDA gates (step 3) and this close-out (step 4). 26 commits after the merge commit, about nine
hours from the merge to the close-out, several of them under the exclusive perf lock (the sssp
and cycle_count spot checks of the merge, the DD smoke run, the three CUDA campaigns and their
repeats, the final re-checks). Nothing is pushed (the orchestrator opens the pull request). Size of
the branch against the merge commit c8d5d5d (added lines, tracked files):
Area |
Lines added |
|---|---|
Library ( |
2,880 |
Tests ( |
780 |
Parity harness, compat driver, CI ( |
1,560 |
Documentation (Markdown pages, ADR 0020, CHANGELOG) and the parity certificate |
1,170 |
Committed CUDA fixtures ( |
4,620 |
Parity and performance records ( |
67,760 |
What was built#
cycle_counton CUDA behind the samecompute()/update()contract as the host backends: the original’s static counters (the naive counter, the work queue with root, edge and implicit two-hop items, its automatic choice) and its update phases (mark_owners,item_counts,count_owned_cycles), the kernels templated on the offset type (withint32_toffsets they are the original’s 32-bit kernels, register for register). Optionscuda_engine,scheduler,work_items; the effective bound limited to 64 withinvalid_argument_error.The resident device graph under set semantics (ADR 0020): the batch merged on the device (the original’s
build_next_rows_kernel, which it runs and discards), a lazily downloaded host copy, device in-edges on first use, Step 0 once per update for every result and the commit, the deletion marks of G_t shared by the delete phase and the device apply.dyng-compat-cycle-enum --backend cudawith the two scopes; the CUDA golden setcycle_count_cuda(24 cases) and its replay (CTestparity.cycle_count.cycle_enum_cuda_0a976ad,ci/gpu_local.sh); the CUDA mode of the performance harness with the clock lock and the machine monitor, the kernel table (registers, stack, occupancy) and the device-memory command.
Acceptance record#
# |
Criterion |
Evidence |
Status |
|---|---|---|---|
1 |
|
|
met |
2 |
No regression from the merge: sssp 495 / 495 on sequential, OpenMP, CUDA; the sssp OpenMP and CUDA gates on roadNet-CA and road_usa; cycle_count CPU parity unchanged; the OpenMP update gate on DD and GitHub |
step 1 ( |
met |
3 |
CUDA histograms bit-identical to the original’s CUDA backend (fixtures, DD k = 3..7, GitHub / Twitch k = 4, COLLAB k = 3, the seed-1 update deltas); CUDA = OpenMP = sequential; k <= 64 with a clear error; memcheck, racecheck, synccheck clean |
|
met |
4 |
CUDA gates in both scopes at locked clocks, >= 20 runs for regions < 10 ms; default clocks recorded; registers and occupancy recorded |
section 4, and on the review-fixed code section 8: every gated reading within its gate at the boost lock (static kernels 0.674-0.998x, updates 0.386-0.930x, chained updates 0.226-0.885x), end to end 0.90-0.99x; the short updates on host clocks on both sides with dynG’s CUDA-event time next to them (deviation 1 of step 5); default clocks recorded; 33 / 33 kernels with the original’s registers, stack and occupancy; device memory 1.000-1.001x |
met, except the COLLAB update: read at the base lock (0.989x original scope, 0.945x resident; |
5 |
The algorithm page covers the CUDA backend; CHANGELOG; this retrospective with the M2 summary and a re-estimate |
steps 2-4 |
met |
Deviations, consolidated#
Every deviation is in the table or list of the step that made it; the ones that matter beyond M2b:
The graph module owns the device merge (ADR 0020):
build_next_rows_kerneland its helpers are the graph’s device apply (PLAN 6.4.1), used for every resident graph underas_setswithout weight columns and with 32-bit vertex ids; other graphs keep M1b’s host apply and re-upload. G_{t+1} is built even for a batch without insertions.The host copy is lazy. After a device apply the host CSR is downloaded when read, on the stream of the state; Step 0 of the next update tests the changes against the device copy (step 5), so a chain of updates never downloads G_t. A fully device Step 0 (the sort on the device) is future work.
Step 0 once per update, through
update_participant::use_normalized(), on every backend (also a CPU speed-up), with a bucket sort (step 5) instead ofstd::stable_sort.The 64-vertex bound applies to the effective bound max(min(k, n), 2), as in the original, and an edgeless graph counts before the check, as in the original.
Files and harness:
work_queue.hpp(host-only) instead ofwork_queue.cuh; noframework/work_items.cuhorexpand_work_itemsoperator yet (one user; M3); the original’s environment tuning variables are not ported; the CUDA fixtures and goldens are a new set next to the unchanged M2a data; the COLLAB update is replayed on cuda only;dyng-compat-cycle-enumusesint32_toffsets on cuda by default.Measurement: the COLLAB update is read at the base clock lock (ADR 0021, Proposed, pending the author); the resident count scope uploads with a 2-cycle count; device memory is compared as the peak of live allocations; two cases of the main campaign were repeated after another user’s GPU job; the short update regions are host clocks on both sides, with dynG’s CUDA-event time recorded next to them (step 5).
Lessons#
A straight port of kernels is the easy half; the container is the hard half. The kernels had the original’s registers on the first build, and every histogram matched. The time went into where the graph lives: the original’s update never produces G_{t+1} on the host, and a host apply alone would have missed the DD gate. Decide where the data lives (ADR 0020) before porting the code that reads it.
Name the scopes before measuring. The original uploads per call; dynG keeps the graph resident. Reading every region in both scopes, defined in
timed_regions/cycle_count.tomlfirst, keeps the comparison honest both ways: the resident scope is not credited for the original’s upload, and the original scope must meet the gate on its own.What runs before a timed kernel is part of the measurement (step 3): a warm-up that was a full count slowed the next kernel by 5-12 % at locked clocks. A warm-up must be as small as what the original’s process does before its region.
Measure memory per gate case, not only time. The update held the prior’s scratch and a duplicate array (1.5-2x the original’s peak); timing alone never showed it.
A harness must write what it measured, per case, and survive a case it cannot measure; on a shared GPU the per-case record also lets one case be repeated alone.
Stable sorts are not free, and neither are shortcuts.
std::stable_sortof 16-byte records cost 1.5x the original’sstd::sort; skipping the sort of sorted lists made the gate pass only on the generator’s input order (step 5). A sort must be judged on every input order.
M2 summary (M2a and M2b)#
M2 ported CycleEnumeration-GPU@0a976ad’s exact k-bounded cycle counting and its DynTruCy-style
update to all three backends in two calendar days (2026-09-27 and 2026-09-28). M2a (the CPU
backends, the parser, the set semantics, the generator, the oracles, the golden corpus, the
OpenMP gates, an independent review with 16 confirmed findings, all fixed) took about eleven
hours on the branch m2-cycle, 51 commits; M2b (the merge, the CUDA backend, the resident graph,
the CUDA gates) about nine hours, 27 commits including the merge. The plan estimated M2 at 2-3
working weeks, the M1a re-estimate at 4-6 days of focused work.
M2a (CPU) |
M2b (CUDA) |
|
|---|---|---|
Parity |
72 / 72 replays of the 24-case corpus of the original’s OpenMP backend (sequential, OpenMP 4 and 56); the parser, CSR, generated batches and normalized batches byte-equal first |
72 / 72 replays of the 24-case corpus of the original’s CUDA backend (cuda, cuda:resident, cuda:int64), 23 / 23 on OpenMP; CUDA = OpenMP = sequential |
Performance |
OpenMP 56 threads: static end to end 0.29-0.93x, update 0.44-0.65x |
CUDA at locked clocks, both scopes (review-fixed code): static kernels 0.67-1.00x, updates 0.39-0.93x, chained updates 0.23-0.89x, end to end 0.90-0.99x; device memory 1.00x |
Correctness tooling |
subset-DP, brute-force and edge-set-recount oracles; the recorded mutations fail the suite |
the shared suites on cuda; the recorded mutations in the CUDA kernels fail the gpu suite; memcheck, racecheck and synccheck clean |
Decisions |
ADR 0010 amendment (set semantics, the unweighted graph) |
ADR 0020 (the resident graph, Step 0 once) and ADR 0021 (the base lock), both Proposed |
What M2 means for M3: cycle_count is the aggregate-delta counterpart of sssp’s fixed point.
Both run through run_update with a normalize stage, participants and one commit. After M2 the
pieces with two users are the resident device graph, the device workspace with pinned
read-backs, and the batch normalization; the persistent grids differ (sssp’s cooperative kernel,
cycle_count’s claim counter). The work items and a device Step 0 have one user and stay out of
the framework until a second algorithm needs them (rule of two).
Re-estimate of the roadmap#
Measured. The M1b retrospective re-estimated M2b at 3-5 days of focused work after the M1b merge; it took about nine hours, with its independent review still to come. Over M1a, M1b, M2a and M2b, a port of a good original took about a day of wall-clock time per milestone including its gates, and each review found real defects (M1a 25, M1b 19, M2a 16) and took about half a day more. What dominates now is (i) measurement time on one shared machine (the exclusive lock, other users’ jobs, the GPU’s power cap), (ii) reviews and their fix steps, (iii) the author’s decisions and account actions, and (iv) merges of parallel branches.
What this changes. The remaining milestones are less mechanical than the ports so far: M3 refactors two algorithms under unchanged parity and performance (every refactor commit re-runs the replays, about 40 minutes, and a full GPU and OpenMP campaign takes three to four hours of lock time), M8 merges two diverged copies of a data structure, M10 needs calibrated float tolerances. So the speed-up of the ports is applied only in part:
Milestone |
Plan (working weeks) |
M1b re-estimate (focused) |
Now: focused effort, incl. review and fix |
Calendar incl. author gates |
Main risk |
|---|---|---|---|---|---|
M2 |
2-3 |
M2b 3-5 days |
done (M2a about 11 h with its review, M2b about 9 h); M2b’s review and fix step 0.5-1 day |
the pull request |
review findings in the device apply |
M3 framework + conformance kit + 0.1 API freeze |
2-3 |
4-7 days |
2-4 days |
1-2 weeks |
parity and gates re-run per refactor commit (lock time); the author’s API sign-off |
M5 Python CPU wheel, CLI, docs |
2-3 |
4-7 days |
2-4 days |
1-2 weeks |
nanobind + scikit-build-core, stubs, the TestPyPI release candidate on a clean machine |
0.1.0 |
12-17 (from M0) |
about 3-4 weeks |
about 1-2 weeks from now |
about 3-5 weeks |
the API review and the release approval; the first hosted runs of every workflow |
0.1.x (M6) |
4-6 |
1-2 weeks |
4-7 days |
2-3 weeks |
the GPU runner decision (O11); CUDA wheel sizes; checkpoint A4 |
0.2.0 (M7-M9) |
8-12 |
2.5-3.5 weeks |
1.5-2.5 weeks |
4-6 weeks |
the operators engine within 1.05x of the fused one; the CBST merge of two diverged copies; memory <= 1.05x on the 2M-hyperedge suites |
0.3.0 (M10-M11) |
8-12 |
2.5-3.5 weeks |
1.5-2.5 weeks |
4-6 weeks |
DynLP’s calibrated tolerances and slotted layouts; H-SOSP’s seven mutations; conda-forge review |
Up to 0.3.0 |
about 32-47 |
about 9-13 weeks |
about 5-8.5 weeks |
about 13-20 weeks |
Recommended order now: an independent review of M2b and its fix step; the pull request of
m2b-cycle-cuda into main; then M3, which starts from both algorithms on all three backends.
M3’s measurement campaigns should be batched (the replays per refactor commit, one full campaign
per group of commits) to keep the lock time within the estimate.
Open items carried forward#
For the author:
Decide ADR 0021 (Proposed): whether the COLLAB update may be read at the base clock lock (the GPU cannot hold the boost lock through its 6.5 s prior under its 230 W power cap); and accept (or amend) ADR 0020 (Proposed), recording the acceptances in GOVERNANCE.md.
The open decisions of the short plan (O3, the NOTICE line); DCO becomes a required check once the organization membership is public.
For M2b’s review, M3 and later:
A fully device Step 0 (the sort on the device as well); the membership test already runs there, so a chain of CUDA updates never downloads G_t (ADR 0020, point 7).
The one-time pinned-buffer allocation of the first static call (about 0.85 ms of DD’s reported static total in the original scope);
warm_up()could pre-allocate it.The clock monitor checks only busy 50 ms samples; non-busy samples below the lock were seen on the long static kernels (
parity/results/M2b.mdsection 4.3). A stricter rule or a finer sampler is a harness improvement for M3’s campaigns.The work items (
work_queue.hpp) and the device merge become framework pieces when a second algorithm uses them (M3, M9).The original’s 9,000-graph fuzz campaign (PLAN 6.4.3: nightly) and sanitizer jobs in CI (M6).
One unexplained exit of
dyng_cycle_count_cuda_testsundercompute-sanitizer racecheckin the second acceptance verification (step 7; 0 hazards, not reproduced in 12 later runs). If it recurs,ci/gpu_local.shshould keep the exit status or signal of the sanitized process (and a core file) so that a crash can be told from a sanitizer failure.