Skip to content

Check freed debug block fill patterns 32 bytes at a time - #97

Closed
janrysavy wants to merge 1 commit into
pleriche:masterfrom
janrysavy:perf/debug-fill-validation
Closed

Check freed debug block fill patterns 32 bytes at a time#97
janrysavy wants to merge 1 commit into
pleriche:masterfrom
janrysavy:perf/debug-fill-validation

Conversation

@janrysavy

@janrysavy janrysavy commented Jul 21, 2026

Copy link
Copy Markdown

Summary

This changes CheckFreedDebugBlockFillPatternIntact to validate 32 bytes per main-loop iteration instead of one 8-byte word. At 4 KiB, throughput improved by 40.18% on AMD Win64 and 14.45% on AMD Win32; Intel improved by 38.31% on Win64 but only 3.30% on Win32. The change adds four UInt64 comparisons before the existing 8-byte remainder loop, while preserving the freed-object prefix check, 4/2/1-byte remainder handling, corruption result, and logging path. The diff adds 16 lines and changes one comment.

This is complementary to #92: that PR can disable fill-pattern diagnostics for selected configurations, while this change speeds up the unchanged diagnostic path when freed-block validation remains enabled.

Tracking issue: #99

Benchmark

The benchmark repeatedly allocates and frees one block in FastMM5 debug mode with stack-trace collection disabled. Results are medians of 25 alternating baseline/candidate process pairs after three warmups. Throughput gain is (baseline time / candidate time - 1) * 100; the 95% interval is a deterministic 10,000-resample bootstrap interval for the paired median.

AMD Ryzen 9 7950X:

Target User size Operations Baseline median Candidate median Throughput gain (95% CI)
Win64 64 B 4,000,000 190.79 ms 178.72 ms 6.58% (6.12% to 8.03%)
Win32 64 B 4,000,000 173.38 ms 171.36 ms 1.28% (0.24% to 1.69%)
Win64 4 KiB 750,000 336.83 ms 240.14 ms 40.18% (40.11% to 40.43%)
Win32 4 KiB 750,000 358.24 ms 312.89 ms 14.45% (14.34% to 14.66%)
Win64 64 KiB 50,000 310.49 ms 217.46 ms 42.91% (42.68% to 43.23%)
Win32 64 KiB 50,000 331.14 ms 284.16 ms 16.34% (14.86% to 17.69%)

Intel Core i7-8750H:

Target User size Operations Baseline median Candidate median Throughput gain (95% CI)
Win64 64 B 2,100,000 203.97 ms 192.86 ms 5.15% (3.94% to 7.62%)
Win32 64 B 2,000,000 188.55 ms 179.00 ms 4.18% (3.00% to 5.04%)
Win64 4 KiB 268,000 311.63 ms 224.39 ms 38.31% (36.36% to 39.89%)
Win32 4 KiB 286,000 364.46 ms 353.56 ms 3.30% (2.68% to 3.45%)
Win64 64 KiB 17,000 291.12 ms 203.33 ms 43.69% (42.19% to 44.60%)
Win32 64 KiB 18,000 340.18 ms 325.99 ms 4.29% (3.85% to 4.82%)

The 4 KiB and 64 KiB gains reproduced on AMD Win32 and Win64 and on Intel Win64, but Intel Win32 improved by only 3.30% to 4.29%, so the result depends strongly on architecture and CPU. Additional 25-pair tests cover 15, 39, 40, and 41 bytes. The 39-byte case exercises the new failed 32-byte loop guard before the existing 8-byte path; the 40/41-byte cases enter the new loop.

The worst AMD result was -2.12% at 39 bytes on Win32, with all Win64 controls positive. The worst Intel result was -1.58% at 15 bytes on Win32 (CI -2.93% to -0.33%), again with all Win64 controls positive. Avoiding those boundary losses would require an additional fallback path, which does not seem justified for debug mode.

Tests ran on an AMD Ryzen 9 7950X and an Intel Core i7-8750H under Windows 11 build 26200, using Delphi Win32/Win64 37.0.59082.6021 with release-style -O+ builds. The Intel runs use fewer operations to keep process duration similar; both builds still perform identical work within each pair, and the pairing and analysis are unchanged.

Correctness

FastMMDebugPatternTest.dpr passes for the DCC 37 baseline and candidate on Win32 and Win64 on both CPUs. Additional compatibility runs pass with DCC 35 and 36 Win32/Win64 and with DCC 37 PurePascal. The test verifies every allocated fill byte for sizes 1 through 1025, then corrupts and restores every byte for freed-block sizes 1 through 65 and 257. It accepts only the expected EInvalidPointer and requires a clean scan afterward, covering the object marker and every 32/8/4/2/1-byte validation remainder.

Full benchmark source, raw AMD and Intel samples, summaries, and correctness tests: https://github.com/janrysavy/FastMM5/tree/ed3229e678bbe4b2455b8435168442d51f430996/perf-review

Scope

The change keeps the current comparison strategy and only reduces loop overhead. It adds no SIMD requirement and does not change the public API or diagnostic contract.

@janrysavy
janrysavy marked this pull request as ready for review July 21, 2026 09:44
pleriche pushed a commit that referenced this pull request Jul 22, 2026
…by checking chunks of 32 bytes at a time.
@pleriche

Copy link
Copy Markdown
Owner

Thanks for the suggestion. I've further improved the routine by coaxing the compiler into reducing the number of branches. The code isn't pretty though.

This routine seems like a good candidate for a SIMD assembly implementation. I'll put it on my to-do list.

@janrysavy janrysavy closed this Jul 22, 2026
@TetzkatLipHoka

Copy link
Copy Markdown
Contributor

This routine seems like a good candidate for a SIMD assembly implementation. I'll put it on my to-do list.

I had a go at it out of curiosity, and it turned out well enough to be worth handing over rather than sitting on. No PR - you said you would rather ask for one, and this is on your list, so this is just the idea plus the numbers. Take it, change it, or ignore it.

The idea

pcmpeqb against a broadcast fill byte, and the results accumulated with pand instead of being branched on, so the loop keeps the single exit test and the data independent run time the scalar version has. One pmovmskb at the end: $FFFF means every byte matched. 64 bytes per iteration:

@Loop64Bytes:
  movdqu xmm2, [rcx + rdx]
  movdqu xmm3, [rcx + rdx + 16]
  movdqu xmm4, [rcx + rdx + 32]
  movdqu xmm5, [rcx + rdx + 48]
  pcmpeqb xmm2, xmm1        //xmm1 = 16 copies of the fill byte
  pcmpeqb xmm3, xmm1
  pcmpeqb xmm4, xmm1
  pcmpeqb xmm5, xmm1
  pand xmm2, xmm3
  pand xmm4, xmm5
  pand xmm2, xmm4
  pand xmm0, xmm2           //xmm0 = accumulator
  add rdx, 64
  cmp rdx, -64
  jle @Loop64Bytes

Two choices that keep it low risk:

  • Only the whole 16 byte units go to the vector helper. The freed object marker and the trailing bytes stay on your existing scalar path, so the diff adds 140 lines and removes none.
  • The vector path only engages from 128 whole vector bytes upward. Below that the executed code is exactly what it is today, so "no regression for small blocks" is a property of the code rather than something I have to prove with a benchmark.

SSE2 is enough, so it fits what the file already does for the move routines: guaranteed under 64-bit, and gated on System.TestSSE and 2 <> 0 for 32-bit.

Numbers

Your alloc/free workload in debug mode with stack traces off, so the routine runs once per iteration on the reuse path. Median of 25 alternating pairs, each sample in a fresh process, Delphi 13.1, one machine (Ryzen 9 7950X):

User size Win64 Win32
64 B (control, path unchanged) -1.0% +1.9%
128 B (control, just below the threshold) -0.7% +2.4%
136 B (first size that engages) +0.3% +4.5%
256 B +3.2% +10.7%
1 KiB +18.9% +36.0%
4 KiB +65.2% +106.5%
16 KiB +87.5% +158.1%
64 KiB +41.6% +80.2%

The part I did not expect: Win32 gains the most, which is exactly where #97 came out weakest (+3.30% on Intel Win32 at 4 KiB). So this lands on the side that the scalar widening could not reach, rather than overlapping with it.

Correctness

In the real allocator: 12 medium block sizes chosen to cover exact multiples of 64, 16/32/48 byte vector remainders and 1/2/3/7/15 byte scalar tails, with every one of 34,949 byte positions corrupted in turn - all detected, on Win32 and Win64, and the unmodified build gives identical results. Separately, the routine in isolation against a plain byte loop for sizes 1 to 600, clean and with every byte position corrupted, deliberately unaligned since a user area is only pointer aligned. PurePascal still compiles and passes, and the four relevant programs from #103 pass.

Two caveats

One CPU and one compiler version, so this is not the cross architecture picture janrysavy produced - the direction was consistent everywhere I looked, but the magnitudes will move.

Also a methodology warning, in case anyone benchmarks this routine: measuring both variants inside one executable is useless here. Below the threshold both run identical code, and that null control still swung by up to 30% from code layout interaction alone. Separate executables and a fresh process per sample brought the noise floor down to about 3%, which is why the table above is worth anything at all.

Happy to send the patch, or a PR if you would prefer one after all - otherwise the snippet above is the whole trick and the rest is bookkeeping.

@TetzkatLipHoka

Copy link
Copy Markdown
Contributor

Correction to the table above: the CPU is an Intel Core i9-12900K, not a Ryzen 9 7950X. I took the AMD designation from the tables in #99 instead of stating the machine I actually measured on. The numbers themselves are unchanged - only the hardware attribution was wrong.

It does change how one line should be read, and in a useful direction. I presented the Win32 gains as landing where #97 came out weakest, and that weak case (+3.30% at 4 KiB) was Intel Win32. Since my measurements are Intel too, that is a closer comparison than I implied, not a cross-vendor one: different generation (Coffee Lake versus Alder Lake), same vendor.

One methodology detail that belongs with the corrected hardware: the 12900K is a hybrid design, and the benchmark pins itself to one logical processor, which on this part is a P-core. E-cores would give different figures. The pinning is identical for both builds and the pairs alternate, so it does not bias the comparison, but the absolute times are P-core times.

Apologies for the noise - better to have it right in the thread than to leave a wrong CPU attached to the numbers.

@janrysavy

Copy link
Copy Markdown
Author

Thanks for sharing this. I reconstructed @TetzkatLipHoka's SIMD approach from the published description and assembly loop, then measured it on an AMD Ryzen 9 7950X and an Intel Core i7-8750H, with both Win32 and Win64. This is not a byte-for-byte rerun of the original Core i9-12900K candidate because the complete patch was not posted.

Both builds are based on upstream commit 823ba351842a69977c509ff74d68acf08b3a1bc1, which includes Pierre's 32-byte scalar implementation. I used Delphi 13.1 with optimization enabled, separate upstream and SIMD executables, FastMM5 debug mode with stack traces and event output disabled, one worker pinned to logical processor 0, three warmups, and 25 alternating fresh-process pairs per size. The reported value is the median paired 100 * (upstream time / SIMD time - 1), so +100% means twice the throughput.

The straightforward reconstructed Win64 integration added a SIMD helper call inside the Pascal checker. Although the helper was not called for the control sizes, its presence made the compiler save five nonvolatile registers on every check and moved the following allocator routines. That produced a repeatable Intel regression of 3.58% at 64 B, with a bootstrap 95% interval reaching -3.92%.

I tried a different Win64 integration to remove that cost. The scalar checker keeps the upstream instruction flow, tests the 128 B crossover once, and tail-jumps to an out-of-line SSE2 checker only for larger blocks. Corruption logging is also an out-of-line cold path. CheckFreedDebugBlockFillPatternIntact, CheckFreeDebugBlockIntact, and all following FastMM5 allocator routines remain at the same addresses as in the upstream executable. Win32 retains the reconstructed helper integration and its original 136 B crossover.

User size Ryzen 9 7950X Win64 Ryzen 9 7950X Win32 Core i7-8750H Win64 Core i7-8750H Win32
64 B control -0.35% -1.76% +1.08% -0.08%
128 B - Win64 SIMD, Win32 control +2.89% -0.36% +7.72% +0.19%
136 B - SIMD on both targets +2.41% +2.05% +4.60% +6.86%
256 B +5.74% +8.09% +8.36% +14.75%
1 KiB +34.28% +44.50% +23.07% +58.19%
4 KiB +79.12% +101.17% +60.72% +116.85%
16 KiB +98.06% +130.98% +85.27% +175.11%
64 KiB +102.53% +140.29% +63.36% +132.01%

The refined Win64 integration removes the control regression. The AMD 64 B result is statistically neutral, with a bootstrap 95% interval of [-0.89%, +0.44%], while the Intel 64 B result is +1.08% with [0.57%, 1.12%]. The material gains begin at 128 B on Win64 and continue to grow through the KiB sizes. The Win32 values in the table are the earlier measurements because the Win32 implementation did not change. The Core i7-8750H results also reproduce the shape of the corrected Core i9-12900K results, although the exact magnitudes differ by generation.

For correctness, I used 12 real medium-block sizes totaling exactly 34,949 byte positions, covering the 0/16/32/48-byte vector remainders and 1/2/3/7/15-byte scalar tails. Every position was corrupted individually and every corruption was detected by the final binaries on AMD and Intel, Win32 and Win64. The existing debug-pattern test passes, the upstream PurePascal path remains unchanged, and the current FastMM5 quick test suite passes all 18 Win32/Win64 runs with the refined source.

The refined patch is available as janrysavy/FastMM5@9e0ce1a on the agent/refined-debug-fill-simd branch.

This suggests that the SSE2 loop is worthwhile for debug-mode freed-block validation. The layout-preserving Win64 implementation has a larger assembly surface than the straightforward helper call, but it avoids charging small blocks for a SIMD path they do not use.

TetzkatLipHoka added a commit to TetzkatLipHoka/FastMM5 that referenced this pull request Jul 26, 2026
Replaces the bulk of CheckFreedDebugBlockFillPatternIntact with an SSE2 loop
that compares 16 bytes at a time and accumulates the results with pand instead
of branching on them, so the single loop exit and the data independent run time
of the scalar version are preserved.

Only whole 16 byte units are vectorised and only from 128 of them upward;  the
freed object marker and the trailing bytes stay on the existing scalar path,
which is left untouched.  Smaller blocks therefore execute exactly the code they
did before.

Offered under PR pleriche#97, where the SIMD implementation was put on the to-do list.
No pull request opened by request;  this branch keeps the work ready.

Tests/ carries the correctness and timing harness:  exhaustive.dpr corrupts all
34,949 byte positions of 12 freed block sizes covering every vector remainder
and scalar tail, and Measure.ps1 pairs two builds as separate processes.
@TetzkatLipHoka

Copy link
Copy Markdown
Contributor

Thanks for taking the time to reconstruct and measure this - the reproduction found something our own numbers were hiding in plain sight.

I reran the control sizes on the Core i9-12900K the original candidate was measured on, this time with a paired percentile bootstrap over the pairwise gains rather than a p5/p95 spread. Baseline 823ba35, candidate = the helper-call integration, Delphi 13.1, one worker pinned to a P-core, 51 alternating pairs, fresh process per sample, 10,000 resamples:

User size Win64 95% CI Win32 95% CI
64 B (control) -1.12% [-1.68, -0.85] +1.42% [+0.96, +1.92]
128 B (control) +0.31% [-0.93, +1.02] +2.54% [+1.75, +3.06]
4 KiB (signal control) +64.44% [+63.95, +64.80] +106.44% [+105.69, +106.85]

The Win64 64 B interval does not contain zero, so the regression reproduces here as well - smaller than your 3.58% on the i7-8750H, but the same sign and the same cause. Win32 is unaffected, which matches your reading.

That corrects our own table: it reported -1.0% at exactly that size and we read it as noise. With 25 pairs and a percentile spread that was not a distinguishable claim in either direction; the bootstrap makes it one. The general point is worth more than the number - a threshold guarantees that small blocks execute the same instructions, but not that the surrounding code keeps the same addresses, and the second half is what a control size is there to catch and ours did not.

On the test side, the exhaustive checker is now FastMM5Test_FillPattern and part of the suite, with the same 12 sizes and 34,949 positions you used, so RunTests.ps1 covers it along with everything else.

Still no pull request from me, per Pierre's request in #101. If the routine is wanted, the layout-preserving integration is plainly the one to take, and your branch already has it.

@janrysavy

Copy link
Copy Markdown
Author

@TetzkatLipHoka, have you benchmarked this in a real-world scenario using an actual application in debug mode? I’m curious what kind of speedup we could expect in practice.

@TetzkatLipHoka

Copy link
Copy Markdown
Contributor

Not before you asked - now there is one, and it is in the branch so it can be rerun anywhere.

FastMM5Bench_AppWorkload runs a mixed allocation workload of the kind an application produces (strings, objects, growing lists, payload buffers) in debug mode. It is a synthetic program shaped like an application, not a real one - RTL only, fixed seed, no I/O - on the grounds that a number nobody else can rerun is not worth much.

The distribution matters more than the total, so the driver reports it (-histogram):

Blocks that can reach the vector path (>136 bytes):  18.26% of blocks, 90.64% of bytes
Total: 32072 freed blocks, 18436579 bytes

Four fifths of the freed blocks are too small to reach the vector path, but they carry under a tenth of the bytes, and the check costs bytes rather than blocks. End to end, 300 rounds per run, 25 alternating pairs, fresh process per sample, bootstrap CI:

Platform stack traces off depth 20 (debug mode default)
Win64 +4.20% [+3.55, +5.86] +4.35% [+3.31, +5.26]
Win32 +6.75% [+5.11, +8.48] +8.46% [+6.82, +8.87]

So roughly 4% of total run time on Win64 and 8% on Win32, for a program that spends a substantial share of its time allocating in debug mode. Well short of the routine level figures, which I think is the honest headline: the check is one part of an allocation, and most allocations are too small to care.

Two details from the same runs bear on your finding.

The per allocation stack trace costs about what the check costs at medium sizes, so at the default depth it dilutes the routine level gain by roughly half - 4 KiB goes from +64% to +32%. It does not remove it.

And the 64 B control moves from -1.12% [-1.68, -0.85] at depth 0 to +3.59% [+3.36, +4.74] at depth 20. Both intervals exclude zero, and at 64 B both builds execute the same instructions - so the layout effect is not only real, its sign depends on the configuration. That strikes me as a stronger argument for your layout-preserving integration than a single figure would be: it is not a fixed 3.58% one could note and live with.

The tools are on the branch next to the test: FastMM5Bench_AppWorkload and MeasureAppWorkload.ps1, and both measuring scripts now report a bootstrap interval instead of a percentile spread.

@janrysavy

Copy link
Copy Markdown
Author

Thank you for this info. I will try it on our applications with heavy XML parsers.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants