You can't train in what the model already knows: the case against "ImageNet for C++"

9 min read Original article ↗

I Benchmarked the AI's "Fast" C++. It Wasn't Faster.

A few days ago I showed that adding "make it as fast as possible" to a C++ prompt roughly doubles the memory-safety violations in what four frontier models hand back. The latency sentence makes the model drop std::span and walk a raw pointer by hand, exactly the construct the C++29 bounds profile exists to ban.

The obvious objection landed in my inbox within the hour, in several flavors of the same sentence: fine, but the fast version is faster. That is the trade. You buy speed with safety, and on the hot path you take that trade every time.

It is a reasonable thing to assume. It is also wrong, and I can show you the cycle counts. The raw pointers did not buy the speed. Something else did, and that something is fully available with the bounds intact. So the trade everyone thinks they are making does not exist: the unsafe version is not a faster version, it is just an unsafe version.

The one task where this is not obvious

Summing a contiguous block of doubles is the right test, because it is the one case where the naive safe loop genuinely is slow, and for a real reason. Floating-point addition is not associative. (a + b) + c is not bit-identical to a + (b + c), so a compiler is not allowed to reassociate a plain sequential sum without your permission. That means this:

double sum(std::span<const double> d) {
    double total = 0.0;
    for (double x : d) total += x;   // serialized on FP-add latency
    return total;
}

compiles, at -O3, to a single scalar accumulator chained through a 3-to-4 cycle floating-point add. Every iteration waits for the previous one to finish. It is bounds-safe, it is readable, and on a Zen 2 core it runs at about 0.97 ns per element no matter what the cache is doing. It is slow, and the models are not wrong to avoid it.

When you ask Claude or Gemini or GPT for the fast version, they all reach for the same idea: break the dependency chain with multiple accumulators so the out-of-order engine can keep several adds in flight. Here is what Gemini's latency answer does, lightly trimmed:

double fast_sum(const double* data, std::size_t size) {     // raw pointer
    double s0=0,s1=0,s2=0,s3=0,s4=0,s5=0,s6=0,s7=0;
    std::size_t i = 0, lim = size & ~std::size_t(7);
    while (i < lim) {
        s0 += data[i+0]; s1 += data[i+1]; /* ... s2..s6 ... */ s7 += data[i+7];
        i += 8;
    }
    double s = ((s0+s1)+(s2+s3)) + ((s4+s5)+(s6+s7));
    while (i < size) s += data[i++];                          // tail
    return s;
}

Eight accumulators, raw pointer indexing, no bounds anywhere. It is fast. The question is whether the raw pointer is doing any of the work.

It is not. Here is the same thing, bounds-safe

Take that exact algorithm and write it over a std::span. Same eight accumulators, same unrolling, but the data is carried with its length and nothing indexes past it:

double safe_fast8(std::span<const double> d) {
    double a[8] = {0,0,0,0,0,0,0,0};
    std::size_t n = d.size(), i = 0, lim = n & ~std::size_t(7);
    for (; i < lim; i += 8)
        for (int k = 0; k < 8; ++k) a[k] += d[i + k];
    double s = ((a[0]+a[1])+(a[2]+a[3])) + ((a[4]+a[5])+(a[6]+a[7]));
    for (; i < n; ++i) s += d[i];
    return s;
}

I put both of those, plus Claude's four-accumulator version, GPT's hand-written AVX2, the naive loop, and std::reduce, into one benchmark and ran it on hz2, a Ryzen 9 3900 with the test core isolated at boot (isolcpus + nohz_full, taskset -c 6, performance governor). g++-13, -O3 -march=native, median of 31 trials, every implementation verified to produce the same sum before any timing started. Array sizes from 512 doubles (lives in L1) up to 32 million (lives in DRAM).

ns per element vs array size, safe vs unsafe summation

Read the green line and the red line. safe_fast8, the bounds-safe span, and Gemini's raw-pointer fast_sum are not close, they are identical to the digit at every single size: 0.11 and 0.11 in L1, 0.12 and 0.12 in L2, 0.31 and 0.31 in DRAM. Same algorithm, span versus pointer, same machine code, same cycles. The bounds safety costs exactly nothing. The naive loop sits up at 0.97 the whole way, four to eight times slower, and Claude's unsafe four-accumulator version is actually worse than the safe eight-accumulator one because four accumulators do not fill the pipeline.

The speed never lived in the raw pointer. It lived in the eight accumulators, which is an algorithm choice, not a safety choice. You can have the dependency-breaking and keep the span. The models bundled the two together because in their training data "fast loop" and "raw pointer" co-occur, not because one needs the other.

GPT's hand-written AVX2 is the one thing that beats safe_fast8, by about 2x, but only while the data is L1-resident, which for the buffer sizes this code targets it rarely is. By L3 it has converged with the safe version, and by DRAM everything bandwidth-bound sits together at 0.31. And even that L1 win is reproducible safely: a span with sixteen accumulators autovectorizes to the same AVX2. GPT hand-rolled an intrinsic that the compiler would have written for it, and the only thing the hand-rolling bought was a reinterpret_cast and 125 lines of macros.

One more line worth your attention. Rebuild the naive safe loop with -ffast-math and it drops from 0.97 to 0.21, matching the multi-accumulator versions, because you have now told the compiler it may reassociate and it does the whole transformation for you. The safe naive loop was one flag away from fast the entire time.

So the trade is a fiction. On this task the unsafe version is not buying speed over a safe version, it is buying speed over a naive version, and the safe-and-fast version exists, sits right on top of it, and the models simply did not write it when you said "fast." If you are going to take the pointer arithmetic, at least know you are getting nothing for it.

So can you just ask for the safe one?

That is the other half. The parent piece measured two framings, neutral and "fast." There is a third I held back, and it is the one that decides whether any of this is fixable at the prompt: the same task with one different sentence appended, write modern, safe C++23, and assume the Core Guidelines and safety profiles are enabled.

I ran it across all four models, same five tasks, same scoring.

safety violations by model under three framings

The "modern, safe" instruction does not nudge the numbers. It deletes them. Claude goes from 2.1 safety violations per sample to 0.45. Gemini from 2.7 to 0.27. GPT from 5.9 to 1.06. And Fable, the model from the previous piece that writes the least safe code of the bunch and refuses to fill a buffer fast, goes to a flat 0.00. Zero. The same model that produces ten bounds violations per sample under "fast" produces none under "safe." A 78 to 100 percent cut, and it lands below the neutral baseline, not just back to it.

Sit with what that means. A model cannot follow an instruction to use an idiom it does not know. Fable writing zero violations on demand is proof that the safe idiom was in there the whole time, fully formed, one word away. The models are not missing the knowledge. They are picking a default, and the default is keyed to the adjective you used.

Why this kills the corpus argument

The C++ Directions Group's proposed fix for AI-generated unsafe C++ is "ImageNet for C++": a large curated corpus of modern idiomatic code that someone assembles so the next generation of models trains on better examples. The premise is that the models write unsafe code because they have not seen enough safe code.

Put the two results together and that premise collapses. You cannot train a capability into a model that it already demonstrates on request, and these models demonstrate perfect bounds-safe C++ the instant you ask for it. The corpus would be teaching std::span to models that already wrote you std::span thirty seconds ago under a different prompt. And the thing the corpus is supposedly buying you, safety at no cost to speed, is already true: the safe version is not slower, as the cycle counts show. There is no gap for the corpus to close. The gap is between the word "fast" and the word "safe" in your prompt, and no amount of training data changes which one you typed.

The lever is the prompt and the gate, not the training set. Two concrete things, the same two as before but now with numbers behind them. One, if you want the hot path, ask for it safe: "fast, bounds-checked, profiles enabled" costs you nothing in cycles and takes your violation count to near zero. Two, run the cppcoreguidelines-pro-bounds-* checks in CI and fail the build on new pointer arithmetic, because the one model that will not be fixed by a nicer prompt is the next one, written by whoever did not read this.

The raw pointers were never a trade. They were a free loss, taken on a default, fixable with a sentence. The fast code was not faster, and the safe code was one word away.


Method: timing on hz2 (Ryzen 9 3900, Zen 2, isolated core 6 via isolcpus + nohz_full, performance governor), g++-13 -O3 -march=native, median of 31 trials, verify-all gate. Safety counts: 4 models x 5 tasks x 8 samples per framing, g++-13 + clang-tidy-20, cppcoreguidelines-pro- as the bounds-profile proxy. Companion to One Sentence Doubles the Safety Violations in AI-Generated C++. Source paper: P4023R0.*