DEV Community

Ross S.
Ross S.

Posted on

Can PL/I Run as Fast as C? A Compiler Optimization Case Study

One of our PL/I benchmarks started out 6.8 times slower than its C counterpart. After we changed how the compiler handled string appends, it ran at about 0.30x the C runtime—roughly 3.3 times faster in that test.

That is a striking result. It is also just one benchmark.

Across a broader suite of 21 small programs, our latest documented geometric-mean ratio was 0.85x at -O2. A ratio below 1.0 means the PL/I version ran faster. This does not mean PL/I beats C everywhere. It means a compiler can make PL/I competitive when it preserves the language's guarantees and gives LLVM useful information.

Here is what we changed, what worked, and what we learned about benchmarking.

The benchmark, in plain English

We wrote each small program twice: once in PL/I and once in C. The set covers sorting, arithmetic, procedure calls, strings, memory management, and input/output. We compiled both versions at the same optimization level and compared their median runtimes.

The ratio is:

PL/I runtime / C runtime
Enter fullscreen mode Exit fullscreen mode
  • 1.0x means roughly equal runtime.
  • Less than 1.0x means PL/I was faster.
  • More than 1.0x means C was faster.

We use the geometric mean of the 21 ratios for the overall score. It gives each benchmark equal weight and is less affected by one extreme ratio than a simple arithmetic average. The latest recorded score at -O2 was 0.85x. That is about a 15% lower geometric-mean runtime for PL/I across this particular set—not a 15% speedup for every program.

The compiler was already close to parity in the earlier baseline, at about 0.98x. The goal was to find repeatable improvements, not to make a dramatic claim from one hand-picked test.

The documented run used seven timed runs per program after a fresh build. Measurements were made on macOS/ARM64 with LLVM 23.1.2 and Apple Clang 17. Results will vary by machine and compiler version. The harness also checks program output; floating-point cases use a tolerance, and decimal cases are flagged when the C and PL/I programs do different work. A separate forced-checks run helps guard PL/I behavior.

PL/I can tell the compiler useful things

An optimizer works best when it can prove facts about the program. Some PL/I rules make those facts easier to establish:

  • Ordinary AUTOMATIC local variables do not overlap unless the program uses features such as BASED or DEFINED that can create aliases.
  • A counted loop such as do i = 1 to n has a control variable the body cannot change.
  • Declarations such as FIXED BINARY(p) and CHARACTER(n) describe numeric precision and string capacity.

These facts do not magically make code faster. The compiler must carry them into LLVM in a sound way. For example, it must not mark a DEFINED overlay as disjoint from the storage it overlays.

In C, pointers can alias unless the compiler proves otherwise or the programmer provides a promise such as restrict. PL/I's rules can provide the compiler with some of that information directly—but only for the cases where the guarantee really holds.

What we optimized

These are the six workloads we specifically targeted. Ratios are approximate and show PL/I runtime divided by C runtime at -O2.

Benchmark Before After Result
String operations (string_ops) 6.8x 0.30x About 3.3x faster than C in this test
Procedure calls (proc_calls) 2.8x 1.00x Parity
Dynamic stack (controlled_stack) 1.9x 0.86x Faster than C in this run
Output throughput (io_throughput) 1.5x 0.83x Faster after buffering
Wide decimal arithmetic (bcd_decimal) 2.2x 2.26x Still slower; see caveat below
Condition handlers (on_conditions) 1.5x 1.47x Handler dispatch remains costly

You can find the source code in the benchmarks/ directory. Four of these six reached parity or ran faster than C. The other two are useful reminders that one optimization strategy cannot fix every workload.

1. Remove runtime calls from hot loops

Some general-purpose language operations need runtime helpers. A helper call on every pass through a loop can add up quickly.

For example, MOD(i, 2) can use a simple bit operation when the compiler proves that i is non-negative. The general path remains for values where PL/I's sign rules require it. The compiler also maps common math functions to LLVM intrinsics, so LLVM can choose an implementation for the target machine.

The important part is the proof: the fast path is used only when the compiler knows it preserves the result.

2. Tell LLVM when memory is truly separate

The compiler attaches alias information to eligible local variables and emits cleaner loop forms. LLVM can then move loads and stores more freely and vectorize some loops. SIMD instructions can process several independent values in one instruction.

The compiler stays conservative around BASED pointers, DEFINED overlays, and other storage that may alias. We added tests for both sides: cases where alias information should be emitted, and cases where it must not be.

3. Pass safe scalar parameters by value

With by-reference parameters, a procedure receives an address. The value might be changed through that address, which makes it harder for an optimizer to reason about.

For eligible internal procedures, pli-llvm now passes a scalar by value when it can prove that the procedure does not write through the parameter or need its address. Cases with uncertain aliasing or special calling behavior keep the by-reference path. The proc_calls benchmark moved from about 2.8x to parity at -O2.

4. Append strings instead of copying them over and over

Suppose a loop builds a string by adding one short piece at a time. A naive implementation allocates a new buffer on each iteration, copies the entire string so far, then adds the new piece. The work grows roughly as O(n²) because earlier characters are recopied again and again.

For safe assignments such as result = result || temp, the compiler now appends the new piece to the existing buffer. It copies only the new part, so the work grows roughly as O(n) with the final string length. That changed string_ops from 6.8x to about 0.30x.

This optimization is limited to cases where the compiler can prove the source and destination do not overlap in a harmful way. Uncertain cases still use the general implementation. We separately fixed a temporary-buffer leak: one test's resident memory at -O0 fell from about 109 MB to about 1 MB. That was a memory fix, not a speedup at -O2.

5. Use SIMD for a specific substring search

For a short, constant search string, the compiler can search for its first character and compare candidate blocks of bytes. On ARM64, the comparison can use NEON SIMD instructions.

One INDEX benchmark ran about 14 times faster than its C strstr baseline. The test uses a fixed search word whose first character is absent from the repeated buffer, so this is a result for that specific pattern—not a general promise about all substring searches. The buffer changes during the loop so the compiler cannot move an unchanged search out of the timed work.

6. Batch small output writes

The io_throughput test made many small writes for each line. The runtime now collects standard output in an 8 KiB buffer and sends larger chunks instead. Fewer low-level writes moved the benchmark from about 1.5x to 0.83x at -O2.

This helps explain why profiling the runtime matters too. Not every performance problem is in the generated arithmetic.

Where the results need a caveat

Decimal arithmetic: FIXED DECIMAL preserves decimal precision and rounds according to PL/I rules. Some C baselines use binary double, which is a different numeric model. These programs can do different work and return different answers, so their timing ratios are not a perfectly equivalent comparison.

Condition handling: PL/I condition handlers have observable semantics. Dispatching to an ON handler costs more than a simple C branch, and the compiler cannot remove behavior that the program can observe.

File I/O and Mandelbrot: Buffering improved the standard-output benchmark, but file I/O still has runtime overhead. Mandelbrot's early-exit loop is also harder to vectorize than a loop where every iteration does the same work.

So the honest conclusion is not “PL/I is faster than C.” It is: PL/I can run at or near C speed on many workloads, and sometimes faster, when the compiler takes advantage of facts the language guarantees.

How we checked the optimizations

We often started by inspecting the generated LLVM IR. A hot loop that still called a runtime function, recalculated a disabled bounds check, or copied an entire string gave us a concrete lead.

Then we rebuilt, timed repeated runs, checked program output, and added tests for the safety boundaries. A test for a fast non-negative MOD path should be paired with one proving that negative inputs still take the general path. An alias optimization needs tests showing both where it applies and where it does not.

This is important because a stale executable, a skipped operation, or an unsound compiler assumption can produce a very convincing benchmark number.

Run the benchmarks

From the repository root:

cmake --build build/cmake -j
python3 benchmarks/run_benchmarks.py --force --runs 7
Enter fullscreen mode Exit fullscreen mode

The benchmark sources and C baselines are in the project repository. Keep the platform, compiler versions, and optimization settings the same when comparing runs.

The larger lesson is simple: find the cost in the generated code, use only facts the program actually guarantees, and measure again. That process is more reliable than assuming a language—or a compiler flag—will make a program fast by itself.

Top comments (0)