Skip to content

Commit 74f9e2a

Browse files
plxclaude
andcommitted
Benchmark plxcobol; add STRING-APPEND, %, and a VARYING->FOR fast path
Add plxcobol to the benchmark harness across all five workloads. Two changes make the comparison fair and idiomatic: - STRING-APPEND <expr> TO <var> lowers to the plx_strbuild string builder, the COBOL counterpart of the other dialects' append operators, so in-loop string building is amortized O(1) (10 ms vs plpgsql's 1743 ms). - A PERFORM VARYING v FROM a BY 1 UNTIL v > b (or >= b) counting loop now lowers to a plpgsql integer FOR loop instead of a WHILE with a manual step; this brings arith from 2.6x to parity with plpgsql. Other VARYING forms keep WHILE. - % is accepted as the modulo operator in expressions. Result on PostgreSQL 18.4: plxcobol matches plpgsql on arith (0.97x), strbuild (10 ms), iter (1.09x), and call (1.04x). Branch is 1.32x because EVALUATE lowers to CASE rather than the IF/ELSIF chain the other dialects use. Adds a STRING-APPEND regression case; all 9 suites pass on PostgreSQL 17 and 18. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e5a2a56 commit 74f9e2a

7 files changed

Lines changed: 242 additions & 21 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,23 @@ All notable changes to plx are recorded here. The format follows
44
[Keep a Changelog](https://keepachangelog.com/), and plx uses the extension
55
version in `plx.control` (currently `1.0`).
66

7+
## [Unreleased]
8+
9+
### Added
10+
11+
- plxcobol: `STRING-APPEND <expr> TO <var>`, which lowers to the `plx_strbuild`
12+
string builder (the COBOL counterpart of the other dialects' append operators),
13+
and `%` as the modulo operator in expressions.
14+
15+
### Changed
16+
17+
- plxcobol: a `PERFORM VARYING v FROM a BY 1 UNTIL v > b` (or `>= b`) counting
18+
loop now lowers to a plpgsql integer `FOR` loop instead of a `WHILE` with a
19+
manual step, which is faster; other `PERFORM VARYING` forms still use `WHILE`.
20+
- Benchmarks now cover plxcobol (`bench/BENCHMARKS.md`): it matches plpgsql on
21+
arith, strbuild, iter, and call, and is about 1.3x on the branch workload
22+
because `EVALUATE` lowers to `CASE`.
23+
724
## [1.1] - 2026-07-14
825

926
### Added

bench/BENCHMARKS.md

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ Five workloads were measured on PostgreSQL 18.4 in the development container
1010
- strbuild: build a 200,000-character string one character at a time in a loop
1111
(text handling with the natural in-language idiom for each language). plpgsql
1212
uses `s := s || 'x'`. The plx dialects use their append operator (`s << 'x'`,
13-
`$s .= 'x'`, `s += 'x'`), which plx lowers to the plx_strbuild builder.
13+
`$s .= 'x'`, `s += 'x'`, and `STRING-APPEND 'x' TO S` in plxcobol), which plx
14+
lowers to the plx_strbuild builder.
1415
- iter: sum a `bigint` column over a 1,000,000-row table (SPI and per-row
1516
marshalling).
1617
- branch: a four-way conditional per element over 2,000,000 elements (branch
@@ -26,33 +27,34 @@ reported. The harness is `bench/run_bench.py`. All languages run in one database
2627
plx uses plx-prefixed language names, so it coexists with the native plruby,
2728
plphp, plperl, and plpython3u.
2829

29-
plxruby, plxphp, plxjs, and plxpython3 transpile to plpgsql at `CREATE FUNCTION`
30-
time, so at run time they execute as plpgsql. plperl and plpython3u run their own
31-
embedded interpreters and retrieve rows through SPI.
30+
plxruby, plxphp, plxjs, plxpython3, and plxcobol transpile to plpgsql at
31+
`CREATE FUNCTION` time, so at run time they execute as plpgsql. plperl and
32+
plpython3u run their own embedded interpreters and retrieve rows through SPI.
3233

3334
## Results
3435

3536
Times are milliseconds; the multiplier is relative to plpgsql (lower is faster).
3637

3738
| language | arith | strbuild | iter | branch | call |
3839
|------------|--------------|--------------|---------------|---------------|---------------|
39-
| plpgsql | 53 (1.00x) | 1705 (1.00x) | 92 (1.00x) | 157 (1.00x) | 160 (1.00x) |
40-
| plxruby | 56 (1.06x) | 10 (0.01x) | 103 (1.12x) | 175 (1.11x) | 165 (1.03x) |
41-
| plxphp | 56 (1.07x) | 10 (0.01x) | 106 (1.14x) | 165 (1.05x) | 168 (1.04x) |
42-
| plxjs | 58 (1.10x) | 10 (0.01x) | 109 (1.18x) | 175 (1.11x) | 167 (1.04x) |
43-
| plxpython3 | 58 (1.10x) | 10 (0.01x) | 103 (1.12x) | 165 (1.05x) | 163 (1.02x) |
44-
| plperl | 45 (0.86x) | 12 (0.01x) | 555 (6.00x) | 163 (1.04x) | 302 (1.88x) |
45-
| plpython3u | 74 (1.40x) | 15 (0.01x) | 369 (4.00x) | 111 (0.70x) | 181 (1.13x) |
46-
| plruby | 101 (1.91x) | 53 (0.03x) | 392 (4.24x) | 144 (0.92x) | 476 (2.97x) |
47-
| plphp | 50 (0.95x) | 14 (0.01x) | 263 (2.84x) | 66 (0.42x) | 270 (1.68x) |
40+
| plpgsql | 56 (1.00x) | 1743 (1.00x) | 96 (1.00x) | 161 (1.00x) | 164 (1.00x) |
41+
| plxruby | 56 (1.00x) | 10 (0.01x) | 112 (1.17x) | 163 (1.01x) | 157 (0.96x) |
42+
| plxphp | 54 (0.97x) | 10 (0.01x) | 101 (1.06x) | 174 (1.08x) | 169 (1.03x) |
43+
| plxjs | 58 (1.03x) | 10 (0.01x) | 105 (1.09x) | 162 (1.00x) | 169 (1.03x) |
44+
| plxpython3 | 57 (1.02x) | 10 (0.01x) | 103 (1.07x) | 167 (1.03x) | 159 (0.97x) |
45+
| plxcobol | 55 (0.97x) | 10 (0.01x) | 104 (1.09x) | 214 (1.32x) | 170 (1.04x) |
46+
| plperl | 44 (0.79x) | 12 (0.01x) | 539 (5.61x) | 153 (0.95x) | 300 (1.83x) |
47+
| plpython3u | 73 (1.31x) | 17 (0.01x) | 350 (3.64x) | 109 (0.68x) | 191 (1.16x) |
48+
| plruby | 100 (1.79x) | 53 (0.03x) | 385 (4.00x) | 145 (0.90x) | 497 (3.03x) |
49+
| plphp | 51 (0.90x) | 15 (0.01x) | 259 (2.69x) | 64 (0.40x) | 282 (1.72x) |
4850

4951
The last two rows, plruby and plphp, are the third-party native PL/Ruby and
5052
PL/PHP (see the build notes below); the plx dialects with the same names but a
5153
`plx` prefix are the transpile-to-plpgsql implementations.
5254

5355
In the strbuild column, native plpgsql is the `s := s || 'x'` baseline and the
5456
plx dialects use the builder. This is the intended native-versus-plx comparison:
55-
the same accumulation idiom is 1705 ms in stock plpgsql and about 10 ms in the
57+
the same accumulation idiom is 1743 ms in stock plpgsql and about 10 ms in the
5658
plx dialects. The plx builder is also faster than the native PLs' own in-language
5759
append on this workload (native plruby 53 ms, native plphp 14 ms), because it
5860
mutates one expanded-object buffer in place rather than materializing an
@@ -63,10 +65,18 @@ building). On PostgreSQL 13 to 17 the plx strbuild column would match plpgsql
6365

6466
## Analysis
6567

66-
- The four plx dialects match plpgsql within about 11 percent on every workload,
67-
because the stored function body is plpgsql. There is no run-time translation
68-
cost; the translation happens once at `CREATE FUNCTION`. The small spread among
69-
the dialects is measurement noise.
68+
- The plx dialects match plpgsql within about 10 percent on the arith, iter, and
69+
call workloads, because the stored function body is plpgsql. There is no
70+
run-time translation cost; the translation happens once at `CREATE FUNCTION`.
71+
The small spread among the dialects is measurement noise.
72+
73+
- plxcobol (branch): the one place plxcobol trails is the four-way branch
74+
(1.32x). It is idiomatic in COBOL to write the branch as `EVALUATE`, which
75+
lowers to a plpgsql `CASE` statement, whereas the other dialects and the
76+
plpgsql baseline use an `IF`/`ELSIF` chain. The `CASE` form is modestly slower
77+
here; on the other workloads plxcobol matches plpgsql (its `PERFORM VARYING`
78+
counting loop lowers to an integer `FOR`, and `STRING-APPEND` lowers to the
79+
string builder).
7080

7181
- Row iteration (iter): plpgsql, and therefore the plx dialects, are 2.8x to 6.0x
7282
faster than the embedded PLs (plperl, plpython3u, native plruby, native plphp).

bench/run_bench.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,73 @@ def add(tag, ext, defs):
239239
$x$;""", "SELECT b_call_xpy(%d)" % CALL_N, None),
240240
})
241241

242+
# ---- plxcobol ----
243+
add("plxcobol", None, {
244+
"arith": ("""CREATE FUNCTION b_arith_xcob(n bigint) RETURNS bigint LANGUAGE plxcobol AS $x$
245+
WORKING-STORAGE SECTION.
246+
01 WS-S PIC 9(18) VALUE 0.
247+
01 WS-I PIC 9(18).
248+
PROCEDURE DIVISION.
249+
PERFORM VARYING WS-I FROM 1 BY 1 UNTIL WS-I > N
250+
ADD WS-I TO WS-S
251+
END-PERFORM
252+
GOBACK RETURNING WS-S.
253+
$x$;""", "SELECT b_arith_xcob(%d)" % ARITH_N, None),
254+
"strbuild": ("""CREATE FUNCTION b_str_xcob(n int) RETURNS int LANGUAGE plxcobol AS $x$
255+
WORKING-STORAGE SECTION.
256+
01 WS-S PIC X(1) VALUE "".
257+
01 WS-I PIC 9(9).
258+
PROCEDURE DIVISION.
259+
PERFORM VARYING WS-I FROM 1 BY 1 UNTIL WS-I > N
260+
STRING-APPEND "x" TO WS-S
261+
END-PERFORM
262+
GOBACK RETURNING length(WS-S).
263+
$x$;""", "SELECT b_str_xcob(%d)" % STR_N, str(STR_N)),
264+
"iter": ("""CREATE FUNCTION b_iter_xcob() RETURNS bigint LANGUAGE plxcobol AS $x$
265+
WORKING-STORAGE SECTION.
266+
01 WS-S PIC 9(18) VALUE 0.
267+
01 WS-R TYPE RECORD.
268+
PROCEDURE DIVISION.
269+
PERFORM WS-R OVER "SELECT v FROM bench_data"
270+
ADD WS-R.V TO WS-S
271+
END-PERFORM
272+
GOBACK RETURNING WS-S.
273+
$x$;""", "SELECT b_iter_xcob()", None),
274+
"branch": ("""CREATE FUNCTION b_branch_xcob(n int) RETURNS bigint LANGUAGE plxcobol AS $x$
275+
WORKING-STORAGE SECTION.
276+
01 WS-S PIC 9(18) VALUE 0.
277+
01 WS-I PIC 9(9).
278+
PROCEDURE DIVISION.
279+
PERFORM VARYING WS-I FROM 1 BY 1 UNTIL WS-I > N
280+
EVALUATE WS-I % 4
281+
WHEN 0
282+
ADD 3 TO WS-S
283+
WHEN 1
284+
ADD 1 TO WS-S
285+
WHEN 2
286+
ADD 2 TO WS-S
287+
WHEN OTHER
288+
ADD 4 TO WS-S
289+
END-EVALUATE
290+
END-PERFORM
291+
GOBACK RETURNING WS-S.
292+
$x$;""", "SELECT b_branch_xcob(%d)" % BRANCH_N, None),
293+
"call": ("""CREATE FUNCTION b_leaf_xcob(x int) RETURNS int LANGUAGE plxcobol AS $x$
294+
PROCEDURE DIVISION.
295+
GOBACK RETURNING X + 1.
296+
$x$;
297+
CREATE FUNCTION b_call_xcob(n int) RETURNS bigint LANGUAGE plxcobol AS $x$
298+
WORKING-STORAGE SECTION.
299+
01 WS-S PIC 9(18) VALUE 0.
300+
01 WS-I PIC 9(9).
301+
PROCEDURE DIVISION.
302+
PERFORM VARYING WS-I FROM 1 BY 1 UNTIL WS-I > N
303+
COMPUTE WS-S = WS-S + b_leaf_xcob(WS-I)
304+
END-PERFORM
305+
GOBACK RETURNING WS-S.
306+
$x$;""", "SELECT b_call_xcob(%d)" % CALL_N, None),
307+
})
308+
242309
# ---- native plperl ----
243310
add("plperl", "plperl", {
244311
"arith": ("""CREATE FUNCTION b_arith_pl(n bigint) RETURNS bigint LANGUAGE plperl AS $x$

doc/plxcobol.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,27 @@ CALL "my_proc" USING WS-A WS-B
237237

238238
`COMMIT` and `ROLLBACK` are available in a procedure context.
239239

240+
### Building strings in a loop
241+
242+
Concatenating onto a string in a loop is slow in plpgsql (O(n^2), because text is
243+
immutable and each step copies the whole string). `STRING-APPEND <expr> TO <var>`
244+
lowers to the plx string builder (`plx_strbuild`), whose append is amortized
245+
O(1):
246+
247+
```sql
248+
01 WS-OUT PIC X(1) VALUE "".
249+
...
250+
PERFORM WS-ROW OVER "SELECT name FROM t ORDER BY id"
251+
STRING-APPEND WS-ROW.NAME TO WS-OUT
252+
STRING-APPEND "," TO WS-OUT
253+
END-PERFORM
254+
GOBACK RETURNING WS-OUT.
255+
```
256+
257+
On PostgreSQL 18 this is amortized O(1) per append; on PostgreSQL 13 to 17 it is
258+
correct but not accelerated (the in-place optimization needs a PostgreSQL 18
259+
feature).
260+
240261
## Errors
241262

242263
### Raising
@@ -279,7 +300,8 @@ ASSERT N > 0
279300
- Concatenation and formatting: `DISPLAY` concatenates its operands.
280301
- Figurative constants: `ZERO` / `ZEROS` map to `0`, `SPACE` / `SPACES` to the
281302
empty string, `NULL` to SQL `NULL`.
282-
- Exponent: `**` maps to SQL `^`. The other arithmetic operators are as in SQL.
303+
- Arithmetic: `+`, `-`, `*`, `/` are as in SQL; `**` maps to SQL `^` (exponent)
304+
and `%` is modulo. `COMPUTE` evaluates the expression as SQL.
283305
- Comparisons use SQL three-valued logic. Use `IS NULL` / `IS NOT NULL` to test
284306
for null.
285307

src/plx_transpile.c

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4553,7 +4553,7 @@ cob_lex(Cb *cb, const char *body)
45534553
continue;
45544554
}
45554555
if (*p == '+' || *p == '-' || *p == '*' || *p == '/' || *p == '=' ||
4556-
*p == '<' || *p == '>' || *p == ':')
4556+
*p == '<' || *p == '>' || *p == ':' || *p == '%')
45574557
{
45584558
CBPUSH(CB_OP, p, 1);
45594559
p++;
@@ -4577,7 +4577,8 @@ cob_is_verb(CbTok *tk)
45774577
"EVALUATE", "PERFORM", "DISPLAY", "RAISE", "ASSERT", "GOBACK", "RETURN",
45784578
"CONTINUE", "EXIT", "CALL", "EXECUTE", "COMMIT", "ROLLBACK",
45794579
"RETURN-NEXT", "RETURN-QUERY", "GET", "BEGIN-TRY",
4580-
"OPEN-CURSOR", "FETCH-CURSOR", "CLOSE-CURSOR", "MOVE-CURSOR", NULL
4580+
"OPEN-CURSOR", "FETCH-CURSOR", "CLOSE-CURSOR", "MOVE-CURSOR",
4581+
"STRING-APPEND", NULL
45814582
};
45824583
int i;
45834584

@@ -5477,6 +5478,46 @@ cob_perform(Cb *cb, int ind)
54775478
initStringInfo(&by);
54785479
cob_value(cb, &by, s_until, 1);
54795480
cob_expect(cb, "UNTIL");
5481+
5482+
/*
5483+
* Fast path: PERFORM VARYING v FROM a BY 1 UNTIL v > b (or >= b) is an
5484+
* integer FOR loop, which plpgsql runs faster than a WHILE with a manual
5485+
* increment. Requires BY 1 and a condition "<var> > b" / "<var> >= b".
5486+
*/
5487+
{
5488+
char *bytrim = by.data;
5489+
CbTok *c0 = cob_cur(cb);
5490+
CbTok *c1 = &cb->t[cb->pos + 1];
5491+
bool gt = (c1->kind == CB_OP && c1->len == 1 && c1->s[0] == '>');
5492+
bool ge = (c1->kind == CB_OP && c1->len == 2 &&
5493+
c1->s[0] == '>' && c1->s[1] == '=');
5494+
5495+
while (*bytrim == ' ')
5496+
bytrim++;
5497+
if (strcmp(bytrim, "1") == 0 && c0->kind == CB_WORD && (gt || ge) &&
5498+
strcmp(cob_map(c0->s, c0->len), var) == 0)
5499+
{
5500+
StringInfoData bound;
5501+
5502+
cb->pos += 2; /* control variable and the operator */
5503+
initStringInfo(&bound);
5504+
cob_value(cb, &bound, NULL, 0);
5505+
indent(&cb->cx->out, ind);
5506+
if (gt)
5507+
appendStringInfo(&cb->cx->out, "FOR %s IN (%s )..(%s ) LOOP\n",
5508+
var, from.data, bound.data);
5509+
else
5510+
appendStringInfo(&cb->cx->out, "FOR %s IN (%s )..((%s ) - 1) LOOP\n",
5511+
var, from.data, bound.data);
5512+
cob_block(cb, ind + 1);
5513+
cob_expect(cb, "END-PERFORM");
5514+
indent(&cb->cx->out, ind);
5515+
appendStringInfoString(&cb->cx->out, "END LOOP;\n");
5516+
return;
5517+
}
5518+
}
5519+
5520+
/* general PERFORM VARYING -> WHILE with a manual step */
54805521
initStringInfo(&c);
54815522
cob_value(cb, &c, NULL, 0);
54825523
indent(&cb->cx->out, ind);
@@ -6110,13 +6151,43 @@ cob_move_cursor(Cb *cb, int ind)
61106151
appendStringInfo(&cb->cx->out, "MOVE FORWARD %s FROM %s;\n", n ? n : "1", c);
61116152
}
61126153

6154+
/* STRING-APPEND <expr> TO <var>: lower to the plx_strbuild string builder
6155+
* (amortized-O(1) append), the COBOL counterpart of the other dialects'
6156+
* append operators. */
6157+
static void
6158+
cob_string_append(Cb *cb, int ind)
6159+
{
6160+
StringInfoData v;
6161+
char *tgt;
6162+
PlxLocal2 *l;
6163+
static const char *stops[] = {"TO"};
6164+
6165+
cb->pos++; /* STRING-APPEND */
6166+
initStringInfo(&v);
6167+
if (!cob_value(cb, &v, stops, 1))
6168+
cob_err(cb, "STRING-APPEND requires a value");
6169+
cob_expect(cb, "TO");
6170+
if (cob_cur(cb)->kind != CB_WORD)
6171+
cob_err(cb, "STRING-APPEND requires a receiving field");
6172+
tgt = cob_map(cob_cur(cb)->s, cob_cur(cb)->len);
6173+
cb->pos++;
6174+
l = local_find(cb->cx, tgt, strlen(tgt));
6175+
if (l && !l->is_record)
6176+
l->typ = pstrdup("plx_strbuild"); /* the accumulator becomes a builder */
6177+
indent(&cb->cx->out, ind);
6178+
appendStringInfo(&cb->cx->out, "%s := plx_sb_append(%s, (%s )::text);\n",
6179+
tgt, tgt, v.data);
6180+
}
6181+
61136182
static void
61146183
cob_stmt(Cb *cb, int ind)
61156184
{
61166185
CbTok *tk = cob_cur(cb);
61176186

61186187
if (cob_ci(tk, "MOVE"))
61196188
cob_move(cb, ind);
6189+
else if (cob_ci(tk, "STRING-APPEND"))
6190+
cob_string_append(cb, ind);
61206191
else if (cob_ci(tk, "RETURN-NEXT"))
61216192
cob_return_next(cb, ind);
61226193
else if (cob_ci(tk, "RETURN-QUERY"))

test/expected/plxcobol.out

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,25 @@ SELECT cob_area(2);
154154
12.56636
155155
(1 row)
156156

157+
-- STRING-APPEND lowers to the plx_strbuild string builder; % is modulo
158+
CREATE FUNCTION cob_build(n int) RETURNS text LANGUAGE plxcobol AS $$
159+
WORKING-STORAGE SECTION.
160+
01 WS-S PIC X(1) VALUE "".
161+
01 WS-I PIC 9(9).
162+
PROCEDURE DIVISION.
163+
PERFORM VARYING WS-I FROM 1 BY 1 UNTIL WS-I > N
164+
IF WS-I % 2 = 0
165+
STRING-APPEND "ab" TO WS-S
166+
END-IF
167+
END-PERFORM
168+
GOBACK RETURNING WS-S.
169+
$$;
170+
SELECT cob_build(6);
171+
cob_build
172+
-----------
173+
ababab
174+
(1 row)
175+
157176
-- data set-up for the SQL constructs
158177
CREATE TEMP TABLE cob_items(id int, amount int);
159178
INSERT INTO cob_items VALUES (1, 10), (2, 20), (3, 30);

test/sql/plxcobol.sql

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,21 @@ PROCEDURE DIVISION.
121121
$$;
122122
SELECT cob_area(2);
123123

124+
-- STRING-APPEND lowers to the plx_strbuild string builder; % is modulo
125+
CREATE FUNCTION cob_build(n int) RETURNS text LANGUAGE plxcobol AS $$
126+
WORKING-STORAGE SECTION.
127+
01 WS-S PIC X(1) VALUE "".
128+
01 WS-I PIC 9(9).
129+
PROCEDURE DIVISION.
130+
PERFORM VARYING WS-I FROM 1 BY 1 UNTIL WS-I > N
131+
IF WS-I % 2 = 0
132+
STRING-APPEND "ab" TO WS-S
133+
END-IF
134+
END-PERFORM
135+
GOBACK RETURNING WS-S.
136+
$$;
137+
SELECT cob_build(6);
138+
124139
-- data set-up for the SQL constructs
125140
CREATE TEMP TABLE cob_items(id int, amount int);
126141
INSERT INTO cob_items VALUES (1, 10), (2, 20), (3, 30);

0 commit comments

Comments
 (0)