Skip to content

Avoid per-element temporary string allocation in integer-array joins #415

Open

Description

@dimensionscape

Joining a typed Array<Int> converts each element to a string, stores those strings in a temporary reference array, then copies them into the final string. Multi-digit values allocate individual managed character buffers. A direct integer-conversion control takes about 24 to 25 percent less time for the same output.

Measured on main at 5f7cfef66ed4d9543c6c1a2763bdc2148433435c, AMD Ryzen 9 9950X, release x64 builds with HXCPP_M64 and HXCPP_CPP17, default GC settings and smart strings:

Platform Current integer join Direct conversion control Time reduction
Windows, Haxe 4.3.7, MSVC 19.44.35213 58.92 ms 44.82 ms 23.9%
Linux under WSL, Haxe 4.3.3, GCC 13.3 43.84 ms 32.86 ms 25.0%

Each entry is the median of nine timed batches across three fresh processes per mode, with the order rotated. Each batch joins 8,192 integers 200 times. Each process warms up with ten joins, and a full GC runs before each timed batch outside the measured section. Both paths return managed strings and hash every output byte. Every timed batch returns checksum 2132215416; every warmup returns -1986742138.

The control uses std::to_chars and a temporary native string, then makes the managed result. Every process compares its output byte for byte with Array<Int>.join before timing. The corpus includes zero, negative values, both Int limits and deterministic large values. This is a join microbenchmark; the percentages are not application-wide speedup estimates.

In ArrayBase::joinArray(ArrayBase*, String), typed arrays allocate an Array<String> and call ItemString for each element. For Int arrays, ItemString constructs String(int), which allocates decimal text for multi-digit values. The string-array overload then calculates the final length and copies all the strings. In this corpus, 8,190 of the 8,192 values need a new managed string per join; zero and one reuse cached character strings. Each timed batch therefore creates 1,638,000 temporary element buffers, plus the temporary reference arrays.

Provide a typed integer join path that writes decimal values into the result without retaining a separate managed string for each element. Keep the generic path's behavior for user-defined conversions, null values, separators and recursive formatting. The control requires C++17 only for comparison; a runtime improvement should preserve the project's supported compiler configurations. No Haxe compiler or standard library change is needed.

A conventional reproduction and output-equivalence check is prepared in test/regression/IntArrayJoinThroughput/{Main.hx,build.hxml,stdout.txt}. Without arguments it prints:

integer join matches direct conversion control: true

With runtime or direct it prints three timing samples as JSON. An integer-join workload should also be represented in the benchmark suite so this allocation cost remains visible.

import haxe.Timer;
import haxe.Json;

@:cppFileCode('
#include <charconv>
#include <string>
static String joinWithToChars(Array<int> values) {
	std::string text;
	text.reserve((size_t)values->length * 12);
	for (int i=0; i<values->length; ++i) {
		if (i) text.push_back(44);
		char number[12];
		auto result = std::to_chars(number, number + sizeof(number), values->__get(i));
		text.append(number, result.ptr - number);
	}
	return String::create(text.data(), (int)text.size());
}
static int joinBatch(Array<int> values, bool direct, int count) {
	unsigned int checksum = 0;
	for (int i=0; i<count; ++i) {
		String joined = direct ? joinWithToChars(values) : values->join(HX_CSTRING(","));
		const unsigned char *data = (const unsigned char *)joined.raw_ptr();
		for (int c=0; c<joined.length; ++c) checksum = checksum * 31 + data[c];
	}
	return (int)checksum;
}
')
class Main {
	static function main() {
		var values:Array<Int> = [0, -1, 1, -2147483648, 2147483647];
		for (i in 5...8192) {
			var value = (i * 7919) ^ 0x40000000;
			values.push((i & 1) == 0 ? value : -value);
		}
		var directText:String = untyped __cpp__("joinWithToChars({0})", values);
		if (values.join(",") != directText) throw "join output differs";
		if (Sys.args().length == 0) {
			Sys.println("integer join matches direct conversion control: true");
			return;
		}
		var direct = Sys.args()[0] == "direct";
		var count = 200;
		var warmup:Int = untyped __cpp__("joinBatch({0}, {1}, 10)", values, direct);
		var times:Array<Float> = [];
		var checksum = 0;
		for (_ in 0...3) {
			cpp.vm.Gc.run(true);
			var started = Timer.stamp();
			checksum = untyped __cpp__("joinBatch({0}, {1}, {2})", values, direct, count);
			times.push(Timer.stamp() - started);
		}
		Sys.println(Json.stringify({mode: direct ? "direct" : "runtime", elements: values.length, count: count, times: times, checksum: checksum, warmup: warmup}));
	}
}

Build with haxe -main Main -cpp bin -D HXCPP_M64 -D HXCPP_CPP17. Run bin/Main to check equivalence, then run bin/Main runtime and bin/Main direct in rotated order. On Windows use bin/Main.exe.

Activity

  1. changed the title [-]Avoid a temporary managed string for every element of Array<Int>.join[/-] [+]Avoid per-element temporary string allocation in integer-array joins[/+] on Oct 27, 2026
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

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions