Benchmark Methodology#
This is a reference for results published by onnx-light-cpu. It records
the safeguards needed to interpret this project’s kernels and runtimes; it is
not a general CPU benchmarking guide.
Choose the measurement#
Label the result with exactly one of these layers:
Kernel throughput: call a typed kernel with preallocated inputs and outputs. It measures arithmetic, packing, and conversions, but not runtime dispatch or allocations.
Allocation-inclusive operator call: call the public C++ operator API directly with preconstructed inputs. Include output and scratch allocations, parameter conversion, and coefficient/statistics preparation performed by each call. State which allocations are timed, the selected implementation, and the executor policy. This excludes graph/session dispatch and is not an end-to-end inference result.
Steady-state end-to-end: reuse a prepared evaluator/session and time inference. Exclude parsing, registration, construction, and first-run setup.
Startup: report serialization, registration, session construction, first-run preparation, and cache loading separately.
An isolated kernel result needs a steady-state end-to-end companion: it does
not establish the cost of the registered operator. Use the
examples gallery or the parity drivers in
tools/benchmark_*_parity.py as the starting point.
Allocation-inclusive operator-call results establish only the cost of that operator API. A claim about graph/session speedup also requires a steady-state end-to-end companion; do not extrapolate an operator-call ratio to a model.
Prove the selected kernel ran#
register_kernels() changes process-wide dispatch, while sessions resolve
and cache kernels on their first run. Therefore:
Resolve a built-in
onnx-lightbaseline before registration and construct accelerated sessions after it.For an untimed probe, enable kernel-usage recording and assert the expected library-qualified kernel name; disable it while timing because its mutex changes per-call cost.
Report detected SIMD level, selected algorithm/tuning profile when relevant, and the effective thread count. A missing integration extension is
not supported, not an accelerated result.Check complete outputs after every backend phase, using the operator’s exact contract or an explicit dtype- and reduction-size-appropriate tolerance. Correct output alone does not prove dispatch.
Keep executors separate#
onnx-light-cpu, onnx-light, ONNX Runtime, NumPy, and BLAS can retain
worker pools. Do not construct or run competing backends during a timed phase.
Run the onnx-light-cpu/built-in phase, release its sessions, then measure
NumPy and ONNX Runtime in separate phases; regenerate inputs from the same seed
per phase. Use separate child processes when strict isolation is required.
Report the requested and effective participant counts (including the caller)
and relevant CPU configuration: CPU model/topology, process CPU set or
affinity, and any non-default spin or nested-parallelism setting. Use a
Release build and identify the selected ISA. Shared-runner measurements
are diagnostic, not parity gates.
Use the script safeguards#
Keep the benchmark scripts’ default --warmup, --repeat, and
--max-repeat-time settings unless the experiment documents a reason to
change them. Warmups and timed samples are separately bounded; do not time
first-run preparation. Retain the raw samples and report their median plus the
dispersion the driver emits (for example, IQR or percentiles), rather than a
best observation.
Use identical models, inputs, shapes, and attributes for every backend. If constant weights are prepacked, label that result separately from dynamic weights, whose packing belongs in every invocation.
AVX2 LessOrEqual (October 25, 2026)#
Issue #991 identified the float32 right-scalar case with 4,194,304 elements. The float32 adapter omitted LessOrEqual from its AVX2 dispatch. Disassembly of the baseline right-scalar bulk function showed SSE comparisons followed by multiple unpacking operations to produce boolean bytes. The change reuses the existing ordered AVX2 greater-or-equal comparison with swapped operands, including its scalar-broadcast variants and 32-element byte-packing loop. NaNs remain false; equality, signed zero, tails, scheduling, and allocation behavior are unchanged. Dispatch-pointer assertions verify that the AVX2 functions are selected, and the scalar adapter remains the correctness oracle.
Steady-state end-to-end: AMD EPYC 7763 virtual runner, four logical CPUs,
two physical cores, AVX2 (simd_level=3), GCC 13.3.0 Release, Python 3.13.16,
NumPy 2.5.4. Baseline source was ff886f3 and optimized source b62211d,
both onnx-light-cpu 0.2.0. ONNX Runtime was the 1.31.0 Release wheel,
commit 402979a. The dependency was the published onnx-light 0.2.0
cp313 manylinux x86-64 wheel, SHA-256
8e47ccce3f3a70bbe31cd99c66c144bd3feb1e85b2dae1d1f4c5e849344595ae;
its source commit was not exposed. The integration linked that wheel’s
libraries with matching non-stable Python ABI.
The unchanged backend CLI used native CPU tensor feeds, the original model
and inputs, one session thread, one ORT inter-op thread, ORT sequential
execution, no process pinning (CPUs 0–3 available), and no spin overrides.
These are runtime defaults, not a claim of identical spin policies. CPU was
timed first, then ORT, with the CPU session still alive. Each build had three
independent CLI invocations, each with 20 warmups and 400 measured iterations,
separately capped at one second. All 400 samples were retained per phase.
The higher iteration counts than the CLI’s 8/40 defaults reduce sampling
noise. Untimed kernel recording confirmed onnx_light_cpu::LessOrEqual
and Binary.LessOrEqual.bulk.
For the issue’s exact
test_cpu_lessorequal_v16_right_scalar_float32xfloat32_to_bool_n4194304_benchmark
case, timings in milliseconds were:
Build / run |
CPU median |
CPU p10–p90 |
ORT median |
|---|---|---|---|
Before / 1 |
1.089853 |
1.010284–1.186939 |
1.104075 |
Before / 2 |
0.954795 |
0.858319–1.056098 |
0.922956 |
Before / 3 |
0.845310 |
0.719767–0.968528 |
0.938575 |
After / 1 |
0.320317 |
0.281366–0.384238 |
0.676565 |
After / 2 |
0.426725 |
0.391075–0.509189 |
0.780679 |
After / 3 |
0.458295 |
0.409620–0.546783 |
0.832301 |
The published 1.47x ORT advantage did not reproduce consistently: ORT was faster in only one baseline run. The median of the three CPU medians improved from 0.954795 to 0.426725 ms (2.24x); corresponding ORT medians were 0.938575 and 0.780679 ms. ORT also improved between phases, demonstrating shared-runner variability. CPU was 1.82–2.11x faster than ORT within each post-change run; these diagnostic results are not a parity guarantee.
The same runs covered contiguous and left-scalar inputs at 4,096, 65,536, and 4,194,304 elements. Small cases were approximately unchanged. At 65,536 elements, scalar broadcasts improved about 1.4x, but the contiguous CPU median increased from 0.017182 to 0.018464 ms (ORT also increased from 0.020108 to 0.021485 ms). At 4,194,304 elements, contiguous and left-scalar CPU medians improved from 1.420518 to 0.917705 ms and from 0.992826 to 0.471053 ms. Thus this experiment does not establish a win at every size.
Reproduce each run before and after the change with the same Release AVX2
build. --dtype bool selects the output token in comparison case names;
the regex fixes the inputs to float32:
ROOT=/home/runner/work/onnx-light-cpu/onnx-light-cpu
PYTHONPATH="$ROOT" python -m onnx_light_cpu benchmark \
--test '^test_cpu_lessorequal_v16_(right_scalar|left_scalar|contiguous)_float32xfloat32_to_bool_n(4096|65536|4194304)_benchmark$' \
--dtype bool --threads 1 --onnxruntime \
--warmup 20 --repeat 400 --max-repeat-time 1 \
--output /tmp/lessorequal-results.xlsx
The workbook retains raw samples and percentile summaries. Hardware-counter
profiling was restricted (perf_event_paranoid=4). AVX-512 hardware was
unavailable; no AVX-512-specific performance claim is made.
AVX2 Sigmoid (October 23, 2026)#
Issue #993’s latency gap reproduced on an AMD EPYC 7763 virtual runner exposing
four logical CPUs and two physical cores. This is diagnostic shared-runner
data, not a parity gate or an exact reconstruction of the published campaign.
The build selected AVX2/FMA (simd_level=3), GCC 13.3.0, Release, Python
3.13.16, and NumPy 2.5.4. Baseline onnx-light-cpu 0.2.0 source was
6e96d12 (kernel sources identical to 765a814); optimized source was
ad4c521. ONNX Runtime was the 1.31.0 Release wheel, reporting commit
402979a. onnx-light was the GitHub 0.2.0 cp313 manylinux x86-64 release
wheel, SHA-256
8e47ccce3f3a70bbe31cd99c66c144bd3feb1e85b2dae1d1f4c5e849344595ae;
its source commit was not exposed by the wheel’s build metadata.
Disassembly showed register pressure in the two-vector loop: the generated
float32 Sigmoid function had 37 stack references, versus two after keeping
only one vector live. Setting the sign bit also replaces subtraction and
absolute-value masking when forming -abs(x). The exponential polynomial,
division, exceptional-range handling, executor tuning, inputs, and correctness
tolerances are unchanged. Reciprocal refinement and more aggressive unrolling
were measured and rejected because they regressed throughput.
Steady-state end-to-end: the unchanged backend CLI measures the exact
test_cpu_sigmoid_n1048576_float32_benchmark and
test_cpu_sigmoid_n4194304_float32_benchmark cases, using their native tensor
feeds and original seeded inputs. Kernel recording confirmed
onnx_light_cpu::Sigmoid before timing. Requested session threads were one
or two, inter-op threads one, with no worker affinity, no process pinning
(CPUs 0–3 available), and no spin overrides for either backend. These are the
runtime defaults, not an assertion that the two pools have identical spin
policies. The standard CLI times CPU first, then constructs and times ORT;
the CPU session remains alive during the ORT phase.
Three separate CLI invocations were run per build/thread setting. Each used 20 warmups and 400 repetitions, separately capped at one second, instead of the CLI’s 8/40 defaults to reduce the noise seen in the initial run. All 400 samples were retained in each phase. The table reports the median of the three campaign medians, in milliseconds:
Elements |
Threads |
CPU before |
CPU after |
ORT before / after |
|---|---|---|---|---|
1,048,576 |
1 |
0.474917 |
0.450487 |
0.353392 / 0.359387 |
4,194,304 |
1 |
1.930034 |
1.833611 |
1.495081 / 1.500843 |
1,048,576 |
2 |
0.240246 |
0.227567 |
0.181477 / 0.181848 |
4,194,304 |
2 |
0.994773 |
0.951007 |
0.778456 / 0.778631 |
Two-thread campaign medians ranged from 0.238322–0.240858 ms before to
0.226636–0.234610 ms after for 1,048,576 elements, and
0.992990–1.005287 ms before to 0.928128–0.973949 ms after for 4,194,304.
Thus the gap was repeatable and latency improved, but ORT remains faster.
The standard CLI stores raw samples and percentile summaries in its workbook.
Reproduce each campaign, changing --threads to one for the serial run:
ROOT=/home/runner/work/onnx-light-cpu/onnx-light-cpu
python -m onnx_light_cpu benchmark \
--test '^test_cpu_sigmoid_n(1048576|4194304)_float32_benchmark$' \
--dtype float32 --threads 2 --onnxruntime \
--warmup 20 --repeat 400 --max-repeat-time 1 \
--output /tmp/sigmoid-results.xlsx
Kernel throughput: the extended existing driver uses preallocated buffers, three warmups, 101 samples, one thread pinned to CPU 0, alternating scalar/SIMD order, and a uniform sweep over [-8, 8). This is deliberately separate from the unchanged random-normal backend workload. For 64-byte-aligned buffers, median (IQR) seconds were 0.000456638 (0.000011100) before versus 0.000404111 (0.000011050) after at 1,048,576 elements, and 0.001863161 (0.000016330) versus 0.001683889 (0.000026049) at 4,194,304. With a one-float offset, the corresponding medians were 0.000498466 versus 0.000450157, and 0.002087909 versus 0.001884280. The 65,535-element tail case improved from 0.000027732 to 0.000024777 seconds when aligned, and from 0.000029124 to 0.000026510 with the offset.
cmake -S "$ROOT" -B "$ROOT/build/temp" \
-DONNX_LIGHT_CPU_MAX_SIMD_LEVEL=AVX2 \
-DONNX_LIGHT_CPU_BUILD_BENCHMARKS=ON
cmake --build "$ROOT/build/temp" --target exp_log_throughput --parallel "$(nproc)"
taskset -c 0 "$ROOT/build/temp/exp_log_throughput" 101 sigmoid
Hardware-counter profiling was unavailable (perf_event_paranoid=4).
Disassembly and controlled timing experiments support the register-pressure
diagnosis, not a measured hardware-counter attribution. AVX-512 hardware was
not available; scalar and AVX-512 production paths were not changed.
Normalization moments (October 8, 2026)#
Float32 normalization keeps the single-pass SIMD raw-moments path when its
variance is safely separated from the cancellation floor. Ill-conditioned
reductions below 8,192 elements retain the centered second pass. At or above
that threshold, 256-element blocks accumulate moments relative to their first
element and combine (count, mean, M2) states with Chan’s formula in double
precision. RMS normalization is unchanged because it does not subtract a
mean.
Measurement layer: kernel throughput. The
tools/normalization_moments_throughput.cc driver performs 20 warmups and
101 samples, batches enough calls to process at least 1,048,576 elements per
sample, and reports per-call median and interquartile bounds. The shifted
distribution adds 65,536 to a repeating low-variance pattern and exercises the
cancellation path. The test host was a 13th Gen Intel Core i7-13800H pinned to
one CPU, GCC 14.2.0, Release mode, and AVX2/FMA dispatch
(detected_simd_level=3). Baseline source was 0239e12.
Elements |
Baseline median (s) |
Chan median (s) |
Baseline mean / variance |
Chan mean / variance |
|---|---|---|---|---|
16,384 |
1.19652813e-5 |
1.24434531e-5 |
65536.0078 / 0.374969423 |
65536 / 0.374925584 |
65,536 |
4.79544375e-5 |
4.995775e-5 |
65536 / 0.375009537 |
65536 / 0.375009537 |
The double-precision references for 16,384 elements are 65,535.99980163574 and 0.37492557405494153. Chan aggregation rounds both to the correct float32 results while adding 4.0% latency on this intentionally ill-conditioned case. The unchanged well-conditioned SIMD path avoids this cost. Reproduce the measurement with:
cmake -S . -B build -DONNX_LIGHT_CPU_BUILD_BENCHMARKS=ON \
-DONNX_LIGHT_CPU_BUILD_PYTHON=OFF -DCMAKE_BUILD_TYPE=Release
cmake --build build --target normalization_moments_throughput -j8
taskset -c 0 build/normalization_moments_throughput
BatchNormalization contiguous channels (October 6, 2026)#
For float32 and float64 inputs with a singleton spatial extent and at least 16 channels, BatchNormalization traverses contiguous channel ranges instead of processing one-element spatial slices. Inference uses row tiles of up to 256 channels, including when a single wide row needs parallel scheduling. Training uses 16-channel tiles with bounded worker-local stack storage. Its four accumulation streams and centered variance calculation retain the existing reduction order for each channel. Small channel counts, float16, bfloat16, and larger spatial extents retain the generic algorithm.
Both layouts use the session executor. Layout-specific entry points remain out of line: allowing the NC specialization to consume the generic path’s compiler inlining budget caused spatial-layout slowdowns during development. The contiguous loops are portable C++; GCC emitted baseline SSE2 vector arithmetic in the tested build. This is not a new AVX-specific dispatch path.
Measurement layer: allocation-inclusive operator call. The benchmark is
tools/batch_normalization_throughput.cc. It calls the public kernel API
directly, not a registered graph session. Each case includes output allocation,
coefficient preparation, and running-statistics computation. Tensor construction
is outside timing. The driver uses 20 warmups and 101 samples and prints the
median and interquartile bounds in seconds. An optional output filename saves
all output tensors and statistics as a binary stream, plus raw timings in
<filename>.samples.csv.
Measurements below used a shared Intel Xeon Platinum 8480C host, GCC 13.2.0,
Release -O3 -DNDEBUG, ONNX_LIGHT_CPU_MAX_SIMD_LEVEL=AUTO, and one calling
thread pinned to CPU 0. Baseline source was 24e13a10. The same executable
used either the saved baseline operator library or the changed library, with
unchanged core and low-level dependencies. Two runs reversed backend order;
the table averages the two per-run medians.
Type / mode |
Baseline (s) |
Contiguous channels (s) |
Speedup |
|---|---|---|---|
float32 inference |
3.384225e-4 |
3.3218e-5 |
10.19 |
float32 training |
4.29681e-4 |
5.2693e-5 |
8.15 |
float64 inference |
3.38404e-4 |
5.07675e-5 |
6.67 |
float64 training |
7.15997e-4 |
1.641005e-4 |
4.36 |
All outputs and running statistics were bitwise identical across 48 benchmark configurations, including threshold/tile tails and generic spatial layouts. No final measured regression exceeded both 20% and one microsecond. These shared-host measurements are diagnostic, not a guarantee for other CPUs, thread counts, compilers, or graph-session workloads. No steady-state end-to-end companion was measured, so the speedups above apply only to the direct operator call, not to graph/session inference.
With an already configured onnx-light-integrated shared-library build, reproduce the changed-library run with:
cmake -S . -B build -DONNX_LIGHT_CPU_BUILD_BENCHMARKS=ON
cmake --build build --target batch_normalization_throughput -j8
taskset -c 0 build/batch_normalization_throughput /tmp/bn-patched.bin \
> /tmp/bn-patched.csv
For a before/after comparison, save the baseline
liblib_onnx_light_cpu_kernels.so before rebuilding. Select it using
LD_PRELOAD=/path/to/baseline/liblib_onnx_light_cpu_kernels.so with the same
command and a different output filename, then compare the binary outputs
using cmp. Verify the selected library with LD_TRACE_LOADED_OBJECTS=1;
LD_LIBRARY_PATH alone may not override the executable’s RPATH.
Cast SIMD measurements (September 16, 2026)#
The Cast fast paths convert contiguous float32/float16 buffers with F16C and
float32/bfloat16 buffers with AVX2. Runtime CPU/OS checks and compiled-source
availability gate dispatch; AVX2 alone does not establish F16C support.
The existing codec handles other pairs and scalar tails. Half-to-float vectors
containing NaNs also use the codec to preserve signaling NaN bits. The
float-to-half path canonicalizes NaNs in SIMD and saves/restores MXCSR,
including exception masks and flags: F16C ignores _MM_FROUND_NO_EXC.
No parallel thresholds changed.
Environment and protocol#
Shared Linux runner, AMD EPYC 9V74, two physical cores/four logical CPUs. Both runtimes were restricted to CPU set
0,2, with one- and two-thread policies; workers were otherwise unpinned.GCC 13.3.0, Release
-O3 -DNDEBUG,ONNX_LIGHT_CPU_MAX_SIMD_LEVEL=AVX2, Python 3.13.15, NumPy 2.5.3, ONNX Runtime 1.30.0. ORT’s ISA selection was not capped; the host also supports AVX-512.Baseline CPU source
e0f43c0and the SIMD implementation accompanying this report were freshly built against the same onnx-light source,e03a1ba561451cf991788368397011d35010aa7a. The release wheel lacked the runtime recording API required by this checkout.Existing backend benchmark cases, 20 warmups, 201 samples, two-second per-phase cap. Builds were idle during measurement. Two full runs reversed baseline/SIMD order; each case measured CPU before creating its ORT session. The table reports the median of the two per-run medians, in seconds.
Untimed
cpu_kernel_pathsprobes confirmedCast.float32_to_float16.f16c,Cast.float16_to_float32.f16c,Cast.float32_to_bfloat16.avx2andCast.bfloat16_to_float32.avx2.
Reproduce each build’s measurement with its source tree on PYTHONPATH:
taskset -c 0,2 python -m onnx_light_cpu benchmark \
--test '^test_cpu_cast_.*_(float32_to_float16|float16_to_float32|float32_to_bfloat16|bfloat16_to_float32|float32_to_float32)_benchmark$' \
--threads 1 --repeat 201 --warmup 20 --max-repeat-time 2 \
--onnxruntime --output /tmp/cast-one-thread.xlsx
Repeat with --threads 2 and a different output filename. Keep the raw
and aggregated workbook sheets, including path diagnostics and percentiles.
One-thread end-to-end results#
Shape / conversion |
Baseline (s) |
SIMD (s) |
ORT (s) |
ORT / SIMD |
|---|---|---|---|---|
65537, float32 → float16 |
2.36236e-4 |
6.685e-6 |
8.238e-6 |
1.232 |
65537, float16 → float32 |
7.3977e-5 |
7.176e-6 |
8.087e-6 |
1.127 |
1048576, float32 → float16 |
4.676337e-3 |
7.8058e-5 |
7.9479e-5 |
1.018 |
1048576, float16 → float32 |
1.161491e-3 |
8.6660e-5 |
7.8894e-5 |
0.910 |
256 × 4096, float32 → float16 |
4.659887e-3 |
7.8097e-5 |
7.9389e-5 |
1.017 |
256 × 4096, float16 → float32 |
1.162013e-3 |
8.6741e-5 |
7.8769e-5 |
0.908 |
The scalar, 16-element and 1024-element float16 cases took approximately 1.87–1.98 microseconds, at least 2.18 times faster than ORT. All twelve one-thread float16 cases met 0.9x in this run, but the large reverse conversion is close to the boundary: its second-run p10–p90 interval was 8.7091e-5–9.1728e-5 seconds.
For 1048576 elements, float32 → bfloat16 improved from 3.50051e-4 to 1.17772e-4 seconds; bfloat16 → float32 changed from 8.2494e-5 to 8.1191e-5 seconds. ORT’s Python interface rejected bfloat16 input/output conversion, so these rows have no ORT speedup claim. Additional integer SIMD paths are deferred; their existing codec semantics remain unchanged.
Remaining bottleneck and variability#
The two-thread large-vector medians were 4.3390e-5 seconds (float32 → float16, 1.062x ORT) and 4.6545e-5 seconds (float16 → float32, 0.939x). One matrix run was much slower despite identical element counts: the reverse path ranged from 4.6610e-5 to 7.9379e-5 seconds across runs. A separate longer confirmation (100 warmups, 1001 samples) measured 4.8593e-5 and 4.8744e-5 seconds for the reverse vector/matrix cases, only 0.878x and 0.873x ORT. Thus these shared-runner results do not establish a stable two-thread parity gate.
A separate one-core, preallocated CastConvert timing comparison used
201 samples, 20 warmups and alternating baseline/SIMD calls. At 1048576
elements, float16 → float32 fell from 1.159815e-3 to 8.6440e-5 seconds.
That direct-call time is already approximately the one-thread end-to-end
8.6660e-5 seconds: the remaining serial cost is in the conversion/memory loop,
not registration or tensor allocation. The loop reads/writes six MiB per call
and retains a NaN-preservation check for every eight elements. The direct
identity-copy control was essentially unchanged (9.7877e-5 versus
9.8257e-5 seconds). These ctypes timings include call/dispatch overhead and
use normally distributed finite inputs, unlike the backend corpus; they are
diagnostic, not a subtraction-based runtime-overhead estimate.
Hardware performance counters were unavailable (perf_event_paranoid=4),
so instruction-versus-memory stalls and the two-thread scheduling variation
remain unseparated. Further work should profile that loop and executor
handoff on a dedicated runner before changing parallel thresholds or claiming
stable multithreaded parity.
AVX-512VL narrow vectors and tails (October 4, 2026)#
No production change. The following is a direct-kernel feasibility probe, not evidence of a steady-state end-to-end improvement. No AVX-512VL runtime helper or dispatch tier was added.
Candidate inventory#
NotBool_AVX512processes 64 bytes at a time and has a scalar remainder of up to 63 bytes. A 256-bit AVX-512VL/BW compare with masked loads/stores can avoid that remainder. The existing AVX2 path processes 32 bytes at a time and also has a scalar remainder.AbsFloat32_AVX512processes 16 floats at a time and has a scalar remainder of up to 15. A 256-bit AVX-512VL/F masked tail is possible, but the existing 256-bit AVX kernel already covers eight floats at a time.CastInt64ToFloat32_AVX512DQconverts eight int64 values into eight floats; a 256-bit VL/DQ variant would convert four at a time. This host exposes DQ, but halving the input width does not itself establish a win. Split’s 4x4 transpose instead processes batches of 16 rows by loading four 512-bit vectors, each of which holds four 16-byte rows; narrowing it would require a different transpose, not just a masked tail. AVX-512VNNI is not exposed on this host, so no VL/VNNI result is claimed. VL is a separate CPUID feature, not implied by AVX-512F, BW, or DQ; a production implementation would need its own OS-gated capability check and per-source-mavx512vl(plus the relevant subset flags).
Protocol and raw measurements#
Shared Linux runner, AMD EPYC 9V74 (two physical cores, four logical CPUs),
GCC 13.3.0, -O3 -DNDEBUG -std=c++20. CPU set 0 (one pinned
thread), no other backend running; host flags include AVX2, AVX-512F/BW/DQ/VL
but not AVX-512VNNI. Source: 8d27b4b (refreshed origin/main).
Existing kernel source was compiled with -mavx2 or
-mavx512f -mavx512bw as appropriate. Experimental functions were
compiled only in a disposable probe with
target("avx512f,avx512bw,avx512vl"); the 256-bit Not candidate uses
32-byte vectors and a masked remainder of 1–31 bytes. The 512-bit masked
alternative uses only F/BW, 64-byte vectors and a masked remainder of
1–63 bytes. No library code or dispatch was changed.
The disposable probe source was not retained, so its exact input-generation and timing code and its complete build/run commands are unavailable. The raw measurements below are therefore a historical feasibility observation, not a reproducible benchmark baseline, and must not justify a production change. Any future evaluation must add its probe and commands to the repository before using new measurements to alter dispatch.
Each function used the same preallocated input/output, 100 warmups and seven rotated-order runs (30,000 calls per run below 65,537 bytes; 100 at 65,537). Values are raw nanoseconds per call, in run order, not best times. Before each timed phase, every output byte was checked against the scalar logical-not contract (including noncanonical input bytes 2 and 255) and two output guard bytes were checked for overwrite. Small shapes fit in cache.
bytes existing AVX2 (seven samples) existing AVX-512BW (seven samples)
15 7.37 6.77 7.15 6.77 7.24 6.77 6.77 6.90 6.90 6.50 6.90 6.90 6.50 7.31
31 7.58 7.58 7.58 7.58 7.58 8.04 7.58 12.54 12.07 12.48 12.47 12.01 11.78 12.07
33 3.25 3.25 3.25 3.85 3.50 2.98 2.98 20.32 20.30 20.30 20.57 20.30 20.30 20.76
63 7.58 8.06 7.58 8.00 7.58 8.02 7.58 12.11 11.64 11.64 11.64 12.18 11.64 11.64
127 7.04 7.04 7.04 7.04 7.04 7.04 7.27 11.64 11.84 11.86 11.64 11.64 11.96 11.64
255 8.12 8.54 8.12 8.12 8.35 8.12 8.35 12.72 12.72 12.94 12.75 12.72 12.96 12.78
65537 1246.67 1247.98 1246.38 1249.57 1264.80 1245.87 1247.97
1280.43 1346.43 1347.53 1279.22 1280.23 1287.43 1280.42 (AVX-512BW)
bytes candidate VL/BW 256 (seven samples) alternative masked 512 F/BW (seven samples)
15 2.98 3.36 2.98 2.98 2.98 2.98 2.98 7.85 7.85 7.87 7.87 7.85 8.28 7.87
31 2.98 2.98 2.98 2.98 2.98 2.98 2.98 7.96 9.38 7.87 7.87 8.33 7.85 7.92
33 8.49 8.45 8.12 8.12 8.12 8.56 8.12 7.87 7.93 8.29 8.19 8.33 7.85 7.85
63 2.98 2.98 2.98 2.98 2.98 2.98 2.98 2.98 2.98 2.98 2.98 2.98 2.98 2.98
127 3.52 3.59 3.52 3.73 3.52 3.52 3.52 2.71 2.71 2.71 2.71 2.71 2.71 2.71
255 5.09 4.87 5.14 4.87 4.87 4.87 4.88 3.25 3.25 3.25 3.25 3.25 3.25 3.25
65537 1337.41 1337.41 1364.55 1339.41 1397.00 1337.31 1338.52
1280.12 1280.73 1282.53 1280.32 1282.43 1288.23 1358.84 (masked 512)
For float32 Abs, a separate AVX-512VL/F 256-bit loop with a masked 1–7-float tail was compared against the existing AVX 256-bit and AVX-512F 512-bit functions with the same protocol (7 runs, 30,000 calls below 65,537 elements; 100 calls at 65,537). Bitwise parity was checked for negative zero, infinity and NaN; output guards were checked too:
floats existing AVX 256 (ns/call) existing AVX-512F (ns/call) candidate VL 256 (ns/call)
15 3.52 3.94 3.52 3.52 3.52 3.52 3.52 3.79 3.79 3.79 3.79 4.12 3.79 3.79 2.95 2.71 2.71 2.71 2.71 2.71 2.71
32 2.17 1.90 1.90 1.90 1.90 1.90 1.90 2.95 2.71 2.71 2.71 2.71 2.71 2.71 4.33 4.33 4.33 4.62 4.33 4.33 4.33
1025 35.96 35.74 35.73 37.04 35.73 38.52 35.80 37.02 35.73 35.73 35.73 35.73 35.73 35.74 72.01 73.21 72.43 72.01 73.17 72.01 72.01
65537 4857.50 4817.44 4802.72 4819.54 4811.94 4823.05 4808.83
4958.66 4961.06 5022.85 4962.56 4974.88 4962.86 4953.74 (AVX-512F)
4916.39 4961.56 4922.70 4926.61 5100.47 4959.96 4906.98 (VL)
The narrow masked candidate does win some tail-heavy kernel-only cases (Not 31 bytes: median 2.98 ns versus 7.58 ns AVX2 and 12.07 ns AVX-512BW), but loses at 33 bytes (8.12 ns versus 3.25 ns AVX2), and at 127/255 bytes the existing-ISA masked 512 alternative is faster. The Abs candidate loses substantially at 32 and 1,025 floats. A shape-specific dispatch could avoid those regressions but would add feature probing, build flags and branches for a few nanoseconds of isolated short-tensor work. These measurements neither prove an end-to-end win nor control AVX-512 frequency effects on a dedicated machine. Retain existing dispatch and scalar tails until a representative end-to-end benchmark demonstrates a repeatable improvement.
CDist AVX2 output tiling (October 23, 2026)#
Measurement layer: steady-state end-to-end, addressing issue #987.
The two unchanged registered cases
test_cpu_cdist_m512_k512_n128_sqeuclidean_float32_benchmark and
test_cpu_cdist_m512_k512_n128_euclidean_float32_benchmark use dynamic
float32 inputs of shape [512, 128] and produce [512, 512] outputs.
Their models, input generators, tolerances, and runtime tuning were not changed.
Environment: AMD EPYC 7763, Linux x86-64 Azure VM exposing four logical CPUs
(two cores with SMT), affinity CPU 0, detected SIMD level 3, CMake
ONNX_LIGHT_CPU_MAX_SIMD_LEVEL=AVX2, Release GCC 13.3.0
(-O3 -DNDEBUG, with -mavx2 -mfma only on the AVX2/FMA sources).
Python 3.13.16, NumPy 2.5.4, official onnx-light 0.2.0 Linux cp313 release
wheel, ONNX Runtime 1.31.0 (build commit 402979a), and onnx-light-cpu
0.2.0 built from baseline 28de81093ecfb8a7886e7d77966e55268aa108d9
before and with this change after. Both builds link the same onnx-light
wheel libraries and use matching non-stable Python/nanobind ABIs.
For the named-case CLI measurements below, each backend uses one intra-op
thread (ORT inter-op threads: one, sequential execution); there are no worker
threads to spin. That CLI retains each runtime’s default spin policy, unlike
the alternating parity driver described separately below. Outputs are allocated
by each session; CPU tensor feeds and ORT NumPy views reuse the registered input data.
Two invocations per build use 100 warmups and up to 1,000 repetitions, each
phase bounded at one second. CPU phases precede ORT phases, with ORT sessions
created afterwards. CPU phases retained 921–931 samples before and 1,000
after; ORT phases retained 982–1,000 samples. Kernel recording verified
onnx_light_cpu::CDist and was disabled during timing.
Reproduce the named-case measurements from the repository checkout:
taskset -c 0 python -m onnx_light_cpu benchmark \
--test '^test_cpu_cdist_m512_k512_n128_.*_float32_benchmark$' \
--dtype float32 --threads 1 --onnxruntime \
-r 1000 -w 100 -t 1 -o /tmp/cdist-results.xlsx
Per-invocation medians in milliseconds (not best samples):
Metric / invocation |
CPU before |
CPU after |
ORT before |
ORT after |
|---|---|---|---|---|
sqeuclidean / 1 |
1.068642 |
0.873116 |
0.928530 |
0.931677 |
sqeuclidean / 2 |
1.078174 |
0.880599 |
0.931461 |
0.928876 |
euclidean / 1 |
1.073480 |
0.873606 |
1.012359 |
1.008356 |
euclidean / 2 |
1.066997 |
0.877068 |
1.008072 |
1.008772 |
CPU p90 ranges fell from 1.0867–1.0975 to 0.8863–0.8926 ms (sqeuclidean), and from 1.0863–1.0941 to 0.8841–0.8864 ms (euclidean). The ORT advantage was repeatable but smaller than the published 1.55–1.67x: about 1.15–1.16x and 1.06x respectively. Afterward CPU latency is about 18% lower, beating the local ORT medians by 1.05–1.07x and 1.15x. This shared VM does not establish parity on a dedicated machine, and the dashboard’s per-case build, thread, and spin provenance is unavailable.
Investigation: the parity driver’s Python profile placed over 99% of the
call inside native evaluation; isolated kernel latency was approximately
1.01–1.03 ms before, confirming arithmetic/packing rather than Python
adaptation dominated. Hardware counters were unavailable
(perf_event_paranoid=4). Disassembly showed eight A broadcasts for each
8x8 output tile. The AVX2 float32 fast path uses a 4x16 tile with eight
independent accumulators, halving broadcasts per output while retaining
direct (a-b)^2 accumulation and vector square roots. It packs B on every
call into bounded 16 KiB stack storage; there is no persistent prepacking.
Small shapes, double precision, scalar, and AVX-512 retain their existing paths.
Existing runtime scheduling is unchanged.
The alternating parity driver independently confirmed the one-thread win
with random inputs and explicit non-spinning settings: CPU
spin_policy="park_immediately" and ORT
session.intra_op.allow_spinning="0" /
session.inter_op.allow_spinning="0". The driver records these settings
separately as cpu_spin_policy, ort_intra_op_allow_spinning, and
ort_inter_op_allow_spinning. Its current implementation passes the same
NumPy arrays through each evaluator’s public run API rather than constructing
CPU tensors through a private binding. Those calls include each runtime’s
input adaptation overhead; the named-case CLI measurements above still use
the registered tensor feeds. A two-thread
diagnostic pinned to SMT siblings 0–1 remained slower than ORT: roughly
2.28–2.30 ms before and 2.21–2.22 ms after, versus 0.94–1.02 ms ORT.
This optimization does not resolve that pre-existing scheduling/SMT gap.
Boundary, unaligned-buffer, special-value, and concurrent-executor tests cover
the new tile, including the exact benchmark shape and all partial column widths.