Skip to content

Avoid temporary numeric boxes when popping typed arrays #306

Open

Description

@dimensionscape

Typed numeric Array.pop() returns its value through Dynamic, allocating a numeric box even when the generated caller immediately converts the result back to Int or Float. Stack-like workloads pay allocation and GC costs for values already stored unboxed in the array.

Measured on Windows x64, MSVC 19.44.35213, Haxe 4.3.7 and libhxcpp d734565, release build with HXCPP_M64 and the default collector:

Remove one million last elements Int Float
Typed pop(), then accumulate 6.2797 ms 5.8961 ms
Read last element, resize length down by one, then accumulate 1.9848 ms 2.1693 ms

A second run gave 6.3431 vs 1.9621 ms for Int, and 5.7814 vs 2.1456 ms for Float. These are focused operation measurements rather than application-wide speedup estimates. The indexed control is about 3.2x and 2.7x faster in the first run; it is a comparison of current operations, not a measurement of an implemented fix.

Each case prepares the same million-element array outside the timer, runs a full collection before measurement, drains the array, and verifies its checksum and final length. GC remains enabled. The median is taken from seven samples after one warm-up. Integer values are 1000 + (i & 1023), outside the small integer cache. Float values are 1000.25 + (i & 1023). The timed pop loop explicitly assigns the return value to a typed local:

while (values.length > 0) {
    var value:Int = values.pop();
    sum += value;
}

The control is:

while (values.length > 0) {
    var value = values[values.length - 1];
    values.resize(values.length - 1);
    sum += value;
}

In include/Array.h, ReturnNull, ReturnNull and ReturnNull select Dynamic. Array_obj<ELEM_>::pop reads the typed element, clears the removed slot through resize, then converts that element to the Dynamic return type. Dynamic's constructors in src/Dynamic.cpp allocate numeric objects for these tested values. The generated C++ caller is simply int value = (int)(values->pop()), or its Float equivalent, so the box is immediately unwrapped.

Investigate a runtime-header nullable numeric result that carries null/value state without allocating, converting to Dynamic only when the caller needs an actual dynamic value. The generated typed call above should be able to use a compatible result without a compiler or standard library change. Confirm compatibility across generated call sites before selecting a representation. Reflective __pop and CPPIA entry points still need their existing Dynamic semantics. Do not replace an empty result with numeric zero. Array.shift shares ReturnNull, but shift throughput was not measured here.

Conventional local coverage was added at test/regression/NumericArrayPop/{Main.hx,build.hxml,stdout.txt}. It passes on the current runtime and checks integer boundaries, zero and cached values, fractional floats, NaN, infinities, negative zero, Bool false versus empty null, exact Int64 values above 2^53, nullable elements and empty results. It contains no timing assertions. Add reflected calls and CPPIA coverage if the return representation changes.

Related: #294 concerns boxing inputs to specialized dynamic-array push. This issue concerns nullable outputs from typed arrays. No runtime implementation change has been made.

Activity

  1. dimensionscape commented on Nov 7, 2026

    @dimensionscape
    MemberAuthor

    Moving this past 6.0.0. I tried a return type that keeps the value unboxed, and it can't replace Dynamic without a change in the generator.

    The generator emits pop() and shift() of a typed array in about 25 shapes. Only the cast, ( (int)(a->pop()) ), would gain. The rest use the result as a Dynamic:

    • ::Dynamic n = a->pop(), takeDyn(a->pop()), Std.string(a->pop())
    • a->push(a->pop()), which converts the Dynamic to the element type
    • HX_("v") + a->pop(), a->pop() + 1, x = (x + a->pop())
    • ::hx::IsNull, IsEq, IsNotEq and IsGreater on it, setFixed(..., a->pop()) and switch on it

    A NullReturn<T> holding the value and a null flag, converting implicitly to both T and Dynamic, compiles most of these. Two broke. setFixed takes a cpp::Variant, two conversions away. int + pop() is ambiguous between the built-in + and Dynamic's.

    Both are fixable, but the type would then have to give the same results as Dynamic in every operator. Dynamic's are uneven. int + Dynamic is a double, while Dynamic + int is a boxed Dynamic that stays an Int. Null is 0 in arithmetic and "null" in a string. A type that differs in any of them changes what generated code computes, with nothing at compile time to show it. Native code that calls pop() on a typed array and uses the result as a Dynamic (.mPtr, ->) would stop compiling.

    The cast is the one shape that wants the unboxed value, and only the generator knows it is there. With a typed pop in the runtime (__popInt, say), Haxe could call it when it unwraps the result straight away. The runtime side is small once the generator change exists.

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