================================================================
blaise_mem allocator — benchmark log
================================================================

Tracks performance of the Pascal memory allocator
(runtime/src/main/pascal/blaise_mem.pas) over time.  One entry per
measurement session.  Each entry records:

  - Date
  - Git revision and short commit message describing the state
  - Host environment (CPU, kernel, gcc) when it changes
  - Two columns: libc malloc baseline vs blaise_mem
  - Ratio (blaise_mem / malloc) — lower is better; <1.0 means we
    beat malloc
  - Notes describing what changed since the last entry

Run procedure
-------------
  1. cd runtime && make && make install && cd ..
  2. Recompile both benchmark programs against the fresh RTL:
       compiler/target/blaise --source runtime/src/test/pascal/bench_libc_malloc.pas         ...
       compiler/target/blaise --source runtime/src/test/pascal/bench_blaise_mem_custom.pas  ...
     bench_blaise_mem.pas uses the GetMem builtin and post-cutover
     measures blaise_mem (same as bench_blaise_mem_custom.pas).  Use
     bench_libc_malloc.pas for the libc baseline.
  3. Run each binary three times; record the median.
  4. Keep the same workload constants in both programs:
       SMALL_COUNT   = 1 000 000   x 32 B
       MIXED_COUNT   =   500 000   x 8/32/128/512/2048 B
       REALLOC_COUNT =   100 000   x 5 grow steps
       LARGE_COUNT   =    10 000   x 65 536 B
       RETAIN_COUNT  =   100 000   x 64 B then bulk-free

Host environment
----------------
  CPU      : AMD Ryzen 7 5800X 8-Core (x86_64)
  Kernel   : Linux 6.14.0-37-generic
  Toolchain: gcc 13.3.0, QBE 1.3

Workload columns
----------------
  S = Small alloc/free   (1M x 32 B)
  M = Mixed sizes        (500k x 8-2048 B)
  R = Realloc growth     (100k x 5 steps)
  L = Large alloc/free   (10k x 64 KB)
  H = Retain + free-all  (100k x 64 B)

All values in milliseconds.  Ratio = blaise_mem / malloc per
workload; "—" when malloc baseline is 0 ms.

================================================================
2026-06-02 — commit c9dde98 — vendor QBE 1.2 → 1.3
================================================================

  Workload | malloc | blaise_mem | ratio
  ---------+--------+------------+--------
     S     |   7    |      7     |  1.00x
     M     |   5    |      4     |  0.80x
     R     |   5    |      8     |  1.60x
     L     |   0    |      0     |   —
     H     |   4    |      4     |  1.00x

  Notes:
    QBE updated from v1.2 to v1.3.  Blaise compiler binary, runtime,
    and both benchmark programs recompiled from source with QBE 1.3.

    Median of three runs each.

    No measurable change versus the previous QBE 1.2 baseline (inlining
    phase 2 entry, 2026-05-16).  This is expected: blaise_mem is a
    runtime benchmark and QBE 1.3's new passes (GVN, GCM, if-conversion)
    act on emitted IR.  The compiler binary itself was compiled before
    QBE 1.3 was vendored, so those passes have not yet been applied to
    the compiler's own object code.  Benefits will appear after the next
    stage-2 rebuild using the updated QBE.

    Full test suite wall time (QBE 1.2 binary, baseline): ~12.60 s
    Full test suite wall time (QBE 1.3 stage-2 binary):   ~12.39 s
    Compile step only (QBE 1.2): 7.39 s  →  (QBE 1.3): 7.13 s  (−3.5%)
    TestRunner execution: 5.21 s vs 5.22 s (noise, I/O-bound).

    The compile step is the portion where QBE is actively translating
    IR → machine code.  The −3.5% improvement in that step with the
    QBE 1.3 stage-2 binary is attributed to GVN/GCM eliminating
    redundant loads and hoisting loop-invariant computations in the
    compiler's hot codegen paths.

    Fixpoint clean (stage-2 IR == stage-3 IR, 166479 lines).

================================================================
2026-05-16 — commit d7e9caa — full cutover to blaise_mem
================================================================

  Workload | malloc | blaise_mem | ratio
  ---------+--------+------------+--------
     S     |   8    |      7     |  0.88x
     M     |   5    |      4     |  0.80x
     R     |   5    |      8     |  1.60x
     L     |   0    |      0     |   —
     H     |   5    |      4     |  0.80x

  Notes:
    The compiler RTL, codegen builtins, and all C runtime files now
    route allocations through _BlaiseGetMem / _BlaiseFreeMem /
    _BlaiseReallocMem.  libc malloc is no longer used for Blaise
    strings, class instances, dynamic arrays, or explicit GetMem.

    Median of three runs each.

    blaise_mem now beats libc on three workloads (S, M, H).  The
    R workload (1.6x) remains the only gap.  glibc memcpy uses SSE
    moves for the 16->32->64->128->256 byte realloc chain; closing
    that needs in-place arena-tail growth with a memcpy fast path
    in the Blaise allocator.

    Real-world compile workload (Blaise test suite, 1934 tests):
      Pre-cutover (commit b815793):  ~87-89 s
      Post-cutover (commit d7e9caa): ~80 s   (~10% faster)

    The compile-time win exceeds the microbench gap on R because
    real compilation is dominated by short-lived small allocations
    (string ARC, parser AST nodes), where blaise_mem's freelist
    pop/push beats glibc tcache's bookkeeping.

    A third bench source was added: bench_libc_malloc.pas, which
    uses explicit external 'malloc'/'free'/'realloc' bindings so the
    libc baseline can still be measured post-cutover.

    Fixpoint clean (stage-3 == stage-4, achieved at stage-3).

    Diagnostic _StringReleaseCheck (commit a2c55be) catches header
    corruption in _StringRelease before the wild segfault.  Not
    triggered during the cutover test run; left in for future
    heap-mismatch debugging.

================================================================
2026-05-16 — commit 13bfbd3 — inlining phase 2
================================================================

  Workload | malloc | blaise_mem | ratio
  ---------+--------+------------+--------
     S     |   7    |      8     |  1.1x
     M     |   5    |      4     |  0.8x
     R     |   5    |      8     |  1.6x
     L     |   0    |      0     |   —
     H     |   5    |      4-5   |  0.9x

  Notes:
    Phase 2 of the inliner: case statements and larger bodies (cap
    raised from 8 to 24 statements) are now eligible.  This unlocks
    inlining of SizeClassIndex (8 if/then/else chain) and
    SizeClassBytes (case statement), both used by every call to
    _BlaiseGetMem, _BlaiseFreeMem, and RoundUpToClass.

    Median of three runs each.

    Movement since c16249a (phase 1 inlining):
      S: 9    -> 8   ms  (-11%)
      M: 5    -> 4   ms  (-20%)
      R: 10-11-> 8   ms  (-20-27%)   <-- headline target
      L: 0    -> 0   ms  (unchanged)
      H: 5    -> 4-5 ms  (small win)

    R workload now within 1.6x of malloc (was 2.1x).  The remaining
    gap is in glibc memcpy SSE moves vs Blaise's per-byte fallback
    when realloc crosses size classes.

    Fixpoint clean (stage-2 IR == stage-3 IR).

================================================================
2026-05-16 — commit c16249a + bootstrap binary refreshed
================================================================

  Workload | malloc | blaise_mem | ratio
  ---------+--------+------------+--------
     S     |   7    |      9     |  1.3x
     M     |   5    |      5     |  1.0x
     R     |   5    |     10-11  |  2.1x
     L     |   0    |      0     |   —
     H     |   5    |      5     |  1.0x

  Notes:
    Captures the cumulative effect of three changes shipped in this
    session, with the v0.8.0 bootstrap binary refreshed in-place
    against the verified stage-3 fixpoint of the new source.

    Contributing changes since the 2026-05-16 first-row baseline:

      9847369  fix(rtl): restore large-alloc LIFO cache in
               blaise_mem.  L workload 33 ms -> 0 ms (matches malloc).

      b4ec712  feat(codegen): promote tyPointer and tyPChar locals
               to QBE temps.  Foundational; no direct workload
               change but unlocks downstream wins by removing stack
               slot loads on every pointer access.

      314b537  feat(semantic): mark inline candidates.  Analyser
               only; no codegen change yet.

      c16249a  feat(codegen): inline small leaf functions at call
               sites.  20 candidates inlined compiler-wide
               (RoundUpToClass, MapFailed, PageRound, MemCopy,
               StrData, _StringLength, etc.).  R workload
               12-13 ms -> 10-11 ms (-15%).  S workload 10 -> 9 ms.

    Compared to the original 2026-05-16 first-row baseline
    (before the IsLarge fix landed):

      S: 14 -> 9 ms   (-36%)
      M: 8  -> 5 ms   (-38%)
      R: 13 -> 10 ms  (-23%)
      L: 33 -> 0  ms  (now matches malloc)
      H: 5  -> 5  ms  (unchanged; already at malloc parity)

    Open gaps (next targets):
      R workload (2.1x malloc) — remaining gap is in the
        SmallGetMem/memcpy/SmallFreeMem fallback path that fires
        when realloc crosses size classes.  Inlining SizeClassIndex
        and SizeClassBytes would help further; both currently
        rejected by the analyser (case stmt, > 8 statements).
      S workload (1.3x malloc) — freelist push/pop is already O(1);
        the remaining gap is glibc tcache thread-local caching.
        Not closing this without a redesign.

    Bootstrap binary: releases/v0.8.0/blaise replaced in-place with
    the stage-3 fixpoint binary.  Fixpoint now converges at
    stage-2/3 (was stage-3/4), so future fixpoint runs are faster
    (~2 min instead of ~4).

================================================================
2026-05-16 — investigation: in-place arena-tail realloc growth
================================================================

  Approach (not committed):

    In _BlaiseReallocMem, detect when the block being realloc'd sits
    at the tail of the head arena (block end == arena bump pointer).
    When the new size class still fits in the arena, extend in place
    by advancing the offset and updating the header — saves the
    SmallGetMem + memcpy + SmallFreeMem round trip.

  Measured hit rate on R workload: 100% (every single realloc takes
  the in-place path).

  Measured R workload time:
    baseline (memcpy path)  : ~13 ms
    in-place path           : ~23-26 ms  (REGRESSION)

  Why slower despite saving a memcpy:

    Blaise's codegen does not register-allocate locals — every local
    becomes a stack slot via alloc4 / alloc8 and every read/write
    goes through memory.  The added checks (ArenaHead load, A^.Base
    + A^.Offset arithmetic, two calls to RoundUpToClass) materialise
    as ~30 extra memory operations per realloc.  glibc memcpy for
    16-128 byte payloads is one or two SSE moves — faster than the
    overhead our function-call prologue adds.

  Conclusion: this microbenchmark is codegen-limited, not
  algorithm-limited.  Closing the gap to malloc on small-realloc
  workloads needs compiler-side improvements first:

    - Function inlining for small leaf functions
      (RoundUpToClass, SizeClassIndex, SmallGetMem fast path).
    - Register allocation so locals do not round-trip through
      alloc4/alloc8 stack slots.

  Both improvements would benefit every workload, not just the
  allocator.  Deferring algorithmic allocator tuning until codegen
  catches up.

  Lesson recorded for future allocator work: when borrowing ideas
  from FPC heap.inc or glibc malloc, benchmark them — the better
  algorithm is not always the faster one in a poorly-optimised
  compiler.

================================================================
2026-05-16 — commit b1cb9d0 + IsLarge() fix (uncommitted)
================================================================

  Workload | malloc | blaise_mem | ratio
  ---------+--------+------------+--------
     S     |   7    |     10     |  1.4x
     M     |   5    |      9     |  1.8x
     R     |   5    |     20     |  4.0x
     L     |   0    |      0     |   —
     H     |   5    |      5     |  1.0x

  Notes:
    First entry on the new tracking format.  Captures the state
    immediately after fixing the LIFO-cache bug in LargeGetMem /
    LargeFreeMem.

    Bug fixed: IsLarge() was reading the small-header Flags field
    at offset Ptr-4, but TLargeHeader laid its AllocSize: Int64
    across Ptr-8..Ptr-1, so the Flags slot overlapped with the
    high half of AllocSize and was always zero.  Every large free
    therefore went through the small-block path and never
    populated the LIFO cache, forcing a fresh mmap on every large
    alloc (~32 ms for 10k x 64 KB).

    Fix: restructured TLargeHeader to
      TotalMapped: Int64    (Ptr-16..Ptr-9)
      AllocSize:   Integer  (Ptr-8..Ptr-5)
      Flags:       Integer  (Ptr-4..Ptr-1)
    LargeGetMem now writes Flags := FLAG_LARGE.  IsLarge() reads
    the correct value, free routes through LargeFreeMem, and the
    cache reaches ~100 % hit rate on the L workload.

    Result:
      L workload: 32 ms → 0 ms (matches malloc)
      Cache stats (10 000 calls): 9 999 hits, 1 mmap.

    Open gaps (next targets):
      R workload (4.0x malloc) — realloc still falls back to
        copy on small-block size-class boundaries.  Investigate
        in-place small-block growth when the next size class
        still fits the same arena slot.
      M workload (1.8x malloc) — extra header bookkeeping per
        small alloc.  Possible win: drop per-block AllocSize when
        the size class already encodes it.
      S workload (1.4x malloc) — freelist push/pop overhead vs
        glibc tcache.  Likely needs per-thread caching long term.

================================================================
2026-05-16 — commit b1cb9d0 (pre-fix baseline, for reference)
================================================================

  Workload | malloc | blaise_mem | ratio
  ---------+--------+------------+--------
     S     |   7    |     14     |  2.0x
     M     |   5    |      8     |  1.6x
     R     |   5    |     13     |  2.6x
     L     |   0    |     33     |   —  (cache broken)
     H     |   5    |      5     |  1.0x

  Notes:
    Initial Pascal-allocator measurement after introducing
    blaise_mem.  LIFO cache for large allocations was wired up in
    code but silently broken (see 2026-05-16 fix above).  Kept
    here so the impact of the fix is visible in the log.
