diff --git a/docs/occupancy-2026-10.adoc b/docs/occupancy-2026-10.adoc index 274fb1ee..6d291b05 100644 --- a/docs/occupancy-2026-10.adoc +++ b/docs/occupancy-2026-10.adoc @@ -19,8 +19,10 @@ on 2026-10-14, and checks it against the typed reading. * `julia_allocated_bytes`: the `Base.gc_num()` delta over the stage (cost); * per task (one spawned search): the largest sampled VmRSS of the child process it started (`peak_rss_kib`), the kernel's own VmHWM for it - (`hwm_kib`), and the sample window its children were live in - (`first_sample`, `last_sample`) — what its grade is read from; + (`hwm_kib`), and the sample window its children were live in — `first_sample`, + the first sample taken after its first child started, and `last_sample`, the + last taken before its last was waited for, 0 for never — which its grade is + read from; * `child_peak_rss_kib`: the largest sum of the stage's children's VmRSS at one sampling instant (the stage's occupancy). @@ -74,8 +76,14 @@ whole window (`MemoryProbe`'s `task_grades`, `stage_grade`, `predicted_peak_rss_kib`, `cost_bound_rss_kib`, `reclaimed_kib`) and attaches the result to a recorded stage as `grades`; a record with no sample windows is left ungraded rather than graded on an assumed order. Tasks are read in sampled order -(first sample, ties by record order); a task's drift is its peak when the next -task still held it, and 0 when it had exited. +(first sample, ties by record order). Each handover keeps only the tasks still +live at it, so a task's `drift_kib` is the change in the live peak sum across its +boundary — negative when the boundary also releases an earlier task — and +`release_kib` is everything freed between its start and the next boundary, which +can exceed that task's own peak. The folded drift is the sum of the peaks still +live when the stage ends; the pinned instance's own `drift` is that non-negative +live sum, the signed per-task delta being the extension its header records as +H-2. For the three orders above, the algebra's predicted peak reproduces the measured stage peak exactly — 9204, 9192 and 18380 KiB — and the slack against the cost diff --git a/src/core/memory_probe.jl b/src/core/memory_probe.jl index 8653949c..a4ad4304 100644 --- a/src/core/memory_probe.jl +++ b/src/core/memory_probe.jl @@ -326,8 +326,14 @@ end # hwm peak drift with drift ≤ peak # # * `peak` — the high-water mark this interaction reached, in KiB; -# * `drift` — the net change in memory held by the next interaction's start; -# * `release` = peak − drift — what the frontier reclaims. +# * `drift` — the change in the live peak sum across the interaction's +# boundary, so a boundary that also releases an earlier task is negative; +# * `release` = peak − drift — everything freed between the interaction's start +# and the next boundary, which can exceed the interaction's own peak. +# +# A task's drift subtracts earlier tasks that exited before the next start, so it +# can be negative — the signed-drift extension `Instances/HWM`'s header records +# as H-2. The folded drift is the sum of peaks still live at the stage's end. # # A task's drift subtracts earlier tasks that exited before the next start, so # it can be negative. The folded drift is the sum of peaks still live there. @@ -368,7 +374,7 @@ struct Grade end end -"`release(grade)` — what the frontier reclaims, `peak − drift`." +"`release(grade)` — `peak − drift`: everything freed by the interaction's boundary." release(g::Grade) = g.peak - g.drift "`seq(a, b)` — sequence `b` after `a`: the suffix starts from `a`'s drift." diff --git a/test/unit/test_occupancy_grade.jl b/test/unit/test_occupancy_grade.jl index 215a176b..8b94a0a5 100644 --- a/test/unit/test_occupancy_grade.jl +++ b/test/unit/test_occupancy_grade.jl @@ -111,6 +111,27 @@ _occ_stage(tasks; measured) = OrderedDict{String,Any}( @test !MG._overlaps((1, 2), (3, 4)) end + @testset "the reviewer's three-task example: expiry, not the sum of drifts" begin + # Windows [1,3], [2,10], [4,5] and peaks 100, 1, 100 KiB — CodeRabbit's + # example on #117. A overlaps B and exits before C starts, so the largest + # simultaneous sum is 101 (A+B at samples 2-3, then B+C at 4-5), not the + # 201 a rule that kept every earlier drift would read, and not the 201 KiB + # cost. A's release at C's boundary makes B's delta negative, which is how + # the fold stops charging for it. + tasks = [_occ_task(100; label = "A", first_sample = 1, last_sample = 3), + _occ_task(1; label = "B", first_sample = 2, last_sample = 10), + _occ_task(100; label = "C", first_sample = 4, last_sample = 5)] + occ = _occ_stage(tasks; measured = 101) + @test [g.drift for g in MG.task_grades(occ)] == [100, -99, -1] + @test MG.predicted_peak_rss_kib(occ) == 101 + @test MG.cost_bound_rss_kib(occ) == 201 + @test MG.reclaimed_kib(occ) == 100 # the 100 KiB that never coexist + @test MG.disagreement(occ) === nothing + # The bound the expiry sharpens: 201 KiB resident at once is now the + # disagreement, where before the fix it was the prediction. + @test MG.disagreement(_occ_stage(tasks; measured = 201)) isa String + end + @testset "registration keeps the first child's sample boundary" begin # No background sampler: watch! takes the first sample deterministically. probe = MG.Probe(0.01, ReentrantLock(), MG.TaskTrace[], MG.TaskTrace("(stage)"),