Skip to content

hrx: disjoint transients no longer share an arena slot (arena 256 -> 512 on the same plan) #534

Closed

Description

@bong-water-water-bong

hrx-backend-test's disjoint_transient_plan_packing asserts that two transients whose lifetimes are
disjoint share one arena slot: 4 × ggml_binary_f32, 2 allocations, transients.arena_size == 256.

On the current pin (llama.cpp af04c9f2 + hrx-system 82804197b, host/CPU run, no device) the same graph
plans the same four ggml_binary_f32 dispatches and the same 2 allocations, and the command program still
verifies clean (verify_command_program: ok=1 errors=0), but the arena is 512 bytes:

DUMP packing: loom_libs:ggml_binary_f32/b3 ×4
DUMP packing verify: ok=1 errors=0 arena=512 allocs=2

dispatch/transient-allocator.cpp:364 sets plan.arena_alignment = 256 and :449 rounds the arena up to
it, so two 128-byte transients that used to land in one 256-byte arena now occupy 256 bytes each — i.e. the
lifetime intervals are no longer being coalesced, or the per-allocation alignment is now 256.

Correctness is unaffected (the program verifies, and the packing has no effect on results), so this is a
plan-efficiency regression: every graph with disjoint small transients now allocates twice the arena it
needs. Small in absolute terms (256 bytes here), but it is the mechanism the case exists to protect, and on
larger graphs the same change multiplies the transient footprint.

Found while finishing engine #445's classification; the case is left red (or tagged against this issue)
rather than having its expectation bumped to 512, because 512 is not the behaviour the allocator's own
alignment constants imply.

Activity

  1. bong-water-water-bong commented on Oct 25, 2026

    @bong-water-water-bong
    CollaboratorAuthor

    Bisect answer: the split is not in the 10-18 .. e7e5cd08 range. The case has been red since the guarded
    packer landed on 2026-10-05, and the 256 in the case was the stock packer's number, not this pin's.

    What the graph actually plans

    Same graph as the case (4 x ggml_binary_f32, out0 = add(sum0, c), out1 = add(sum1, f)), host run,
    hrx-system 82804197bc:

    default (guarded packer)                  arena 512   allocs 2   value 2 size 32 offset 0
                                                                    value 7 size 32 offset 256
    GGML_HRX_TRANSIENT_REUSE=legacy (stock)    arena 256   allocs 2   value 2 size 32 offset 0
                                                                    value 7 size 32 offset 0
    GGML_HRX_TRANSIENT_REUSE=legacy at 953b6397 (the 10-18 pin): arena 256, both offsets 0
    default at 953b6397 (the 10-18 pin):      arena 512   value 2 offset 0, value 7 offset 256
    

    So the mechanism is the packer choice, not the sizes: dispatch/transient-allocator.cpp:364 still sets
    arena_alignment = 256 and rounds the arena up to it at :449, and each transient is 32 bytes with alignment
    256; the difference is only whether sum1 may take sum0's freed offset.

    Why it stopped sharing, and why that is intended

    pack_transients_without_false_dependencies() (dispatch/transient-reuse-guard.h, added by d9594130 on
    2026-10-05, "transient arena reuse without false dependencies") hands a freed arena range to the next value only
    when that value's first command already depends, through the program's own data dependencies, on every command
    that used the range before. In this graph the two chains are independent - out0 does not feed out1 - so
    reusing the offset would make the recorder put a write-after-read barrier between two commands that share no
    data, which is what the guard exists to prevent. sum1 gets fresh space instead.

    The guard's own policy bounds the cost: a program keeps the guarded packing only while its arena stays within
    twice the unguarded one plus 8 MiB, and above a 16 MiB unguarded arena it falls back to the stock packer.

    Why the bisect range looked plausible but is empty

    Everything under ggml/src/ggml-hrx/dispatch/ that changed between 953b6397 and e7e5cd08 is one commit,
    61e19a45 (a 7-line zero-element CPY elision in dispatch-scheduler.cpp).
    transient-allocator.cpp and transient-reuse-guard.{h,cpp} are byte-identical across the whole range, and the
    case already measured 512/0/256 at 953b6397. The regression the issue describes is therefore not a regression:
    the expectation predates the guard.

    Resolution

    Fork PR 1bit-MONSTER/llama.cpp#149 (stacked on #148): the case pins the guarded layout
    (arena 512, offsets 0 and 256), documents the stock-arm numbers above as the reason the old 256 was written, and
    the #534 known-fail marker is retired. hrx-backend-test at the pin is 62 passed, 0 failed, 4 known-fail
    (host) and 11 passed, 0 failed (device, HRX0). If the guarded packing is ever measured to cost more than the
    barrier it avoids, GGML_HRX_TRANSIENT_REUSE=legacy is the switch and this case is the check.

  2. bong-water-water-bong commented on Oct 25, 2026

    @bong-water-water-bong
    CollaboratorAuthor

    Intended behaviour, now pinned by fork 1bit-MONSTER/llama.cpp#149 (merged): the guarded transient packer (fork d9594130, 2026-10-05) refuses to share an arena slot between two independent chains because sharing would add a false write-after-read barrier; arena 512 is the correct layout for that graph, 256 was the stock packer's. The test documents both arms (GGML_HRX_TRANSIENT_REUSE=legacy reproduces 256).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions