perf(qbe): shape-aware const-string argument ARC

Const-string args were pinned (AddRef before the call, Release after)
regardless of shape.  ConstArgMode now classifies them: string literals
and named consts are immortal and emit no ARC ops at all; +1 owned
temps (function/method/getter returns) emit a single post-call Release
that consumes the transferred reference — previously these leaked one
buffer per call (the pin pair netted to zero and EmitOwnedArgReleases
deliberately skipped const-string params); every other shape keeps the
pin.  The hot TStringList lookup signatures (Compare, KeyEquals,
HashOf, Find, FindSorted, IndexOf) become const.

Borrowed elision for plain local variables — the larger win — is
deliberately NOT enabled: it is sound at the ARC level, but removing
the balanced pin pairs changes caller register allocation and exposes a
latent use-before-def of a callee-saved register in the native
backend's EmitInterfaceCall (a second-generation compiler then reads
the caller's leftover %r13 as an AST pointer and crashes).  The full
repro and bisect trail are recorded in the bug log; the camPin branch
carries a pointer to it.

Verified through two self-hosted generations plus both fixpoints;
contract pinned by 7 IR tests in cp.test.constarg.pas.
This commit is contained in:
Graeme Geldenhuys 2026-06-10 23:18:24 +01:00
parent 8ce374c0ba
commit 91f44ec823
4 changed files with 397 additions and 31 deletions

View file

@ -26,6 +26,10 @@ procedure _ir_memcpy(Dst, Src: Pointer; N: Int64); external name 'memcpy';
type
ECodeGenError = class(Exception);
{ Caller-side protection mode for a const-string argument — see
ConstArgMode for the classification rules. }
TConstArgMode = (camPin, camBorrowed, camConsume);
{ Growable byte buffer for QBE IR output. Replaces TStringList + .Text:
AppendLine writes bytes directly — no per-line string allocation.
AppendBuffer bulk-copies another buffer in one memcpy.
@ -250,12 +254,25 @@ type
AArgs.Items[i], or '' if that arg was not a value temp (e.g. var param). }
procedure EmitOwnedArgReleases(AArgs: TObjectList; AArgTemps: TStringList;
AParams: TObjectList);
{ Caller-side retain/release for a const-string param. A const param skips
the callee-side _StringAddRef/_StringRelease pair (see 5a5b5d4), so a
fresh transient (rc=0 from _StringConcat or a function returning string)
would be unowned for the duration of the call. Variables/fields/literals
already have a live owner — the AddRef/Release pair nets to zero on them. }
procedure EnsureConstStringRef(const AArgTemp: string; APar: TMethodParam);
{ Caller-side protection for const-string params. A const param skips
the callee-side _StringAddRef/_StringRelease pair (5a5b5d4), so the
caller must keep the argument alive for the duration of the call.
ConstArgMode classifies by argument shape:
camBorrowed — string literals and named string consts: immortal
data, no ARC ops are emitted at all.
camConsume — +1 owned temps (function/method/getter returns): no
AddRef; the single post-call Release consumes the
transferred reference (these previously leaked).
camPin — everything else, including plain local variables —
AddRef before the call, Release after. Locals would
be safe to borrow in principle (the frame's own
reference outlives the call), but eliding their pin
exposes a latent uninitialised-register miscompile
(bugs.txt 2026-06-10) — revisit once that is fixed. }
function ConstArgMode(AArg: TASTExpr; AParams: TObjectList): TConstArgMode;
procedure EnsureConstStringRef(const AArgTemp: string; APar: TMethodParam;
AArg: TASTExpr; AParams: TObjectList);
procedure ReleaseConstStringArgs(AArgs: TObjectList;
AArgTemps: TStringList; AParams: TObjectList);
procedure EmitInheritedCall(ACall: TInheritedCallStmt);
@ -3675,16 +3692,48 @@ begin
end;
{ call $_StringAddRef(l <ArgTemp>) }
function TCodeGenQBE.ConstArgMode(AArg: TASTExpr;
AParams: TObjectList): TConstArgMode;
var
IE: TIdentExpr;
begin
Result := camPin;
if AArg = nil then Exit;
if AArg is TStringLiteral then
Exit(camBorrowed);
if AArg is TIdentExpr then
begin
IE := TIdentExpr(AArg);
if IE.IsImplicitSelfMethod then
Exit(camConsume); { bare zero-arg method call — +1 owned return }
if IE.IsConstant then
Exit(camBorrowed); { named string const — immortal literal data }
{ Plain non-captured locals/params would also be safe to borrow, but
eliding their pin currently exposes a latent uninitialised-register
miscompile downstream (see bugs.txt 2026-06-10, 'borrowed-local
elision exposes EmitInterfaceCall register corruption') — keep them
pinned until that is fixed. }
Exit(camPin); { variable of any kind — pin }
end;
if ExprOwnsRef(AArg) then
Exit(camConsume); { function/method/getter return — +1 owned temp }
end;
procedure TCodeGenQBE.EnsureConstStringRef(const AArgTemp: string;
APar: TMethodParam);
APar: TMethodParam; AArg: TASTExpr; AParams: TObjectList);
begin
if (APar = nil) or (AArgTemp = '') then Exit;
if APar.IsConstParam and (APar.ResolvedType <> nil) and
(APar.ResolvedType.Kind = tyString) then
EmitLine(Format(' call $_StringAddRef(l %s)', [AArgTemp]));
begin
if ConstArgMode(AArg, AParams) = camPin then
EmitLine(Format(' call $_StringAddRef(l %s)', [AArgTemp]));
end;
end;
{ call $_StringRelease(l <ArgTemps[I]>) for each const-string param. }
{ call $_StringRelease(l <ArgTemps[I]>) for each pinned or consumed
const-string argument (borrowed args emitted no AddRef and need no
Release; consumed args take ownership of the +1 temp here). }
procedure TCodeGenQBE.ReleaseConstStringArgs(AArgs: TObjectList;
AArgTemps: TStringList; AParams: TObjectList);
var
@ -3700,8 +3749,11 @@ begin
Par := TMethodParam(AParams.Items[I]);
if Par.IsConstParam and (Par.ResolvedType <> nil) and
(Par.ResolvedType.Kind = tyString) then
EmitLine(Format(' call $_StringRelease(l %s)',
[AArgTemps.Strings[I]]));
begin
if ConstArgMode(TASTExpr(AArgs.Items[I]), AParams) <> camBorrowed then
EmitLine(Format(' call $_StringRelease(l %s)',
[AArgTemps.Strings[I]]));
end;
end;
end;
@ -5785,7 +5837,7 @@ begin
begin
ArgTemp := EmitExpr(TASTExpr(ACall.Args.Items[I]));
ArgTemps.Add(ArgTemp);
EnsureConstStringRef(ArgTemp, Par);
EnsureConstStringRef(ArgTemp, Par, TASTExpr(ACall.Args.Items[I]), MDecl.Params);
QType := QbeTypeOf(Par.ResolvedType);
ArgTemp := CoerceArg(ArgTemp, TASTExpr(ACall.Args.Items[I]), QType);
ArgLine := ArgLine + Format(', %s %s', [QbeParamTypeOf(Par.ResolvedType), ArgTemp]);
@ -5979,7 +6031,7 @@ begin
begin
ArgTemp := EmitExpr(TASTExpr(ACall.Args.Items[I]));
ArgTemps.Add(ArgTemp);
EnsureConstStringRef(ArgTemp, Par);
EnsureConstStringRef(ArgTemp, Par, TASTExpr(ACall.Args.Items[I]), MDecl.Params);
QType := QbeTypeOf(Par.ResolvedType);
ArgTemp := CoerceArg(ArgTemp, TASTExpr(ACall.Args.Items[I]), QType);
ArgLine := ArgLine + Format(', %s %s',
@ -7554,7 +7606,7 @@ begin
ArgTemp := EmitExpr(TASTExpr(ACall.Args.Items[I]));
while ArgTemps.Count < I do ArgTemps.Add('');
ArgTemps.Add(ArgTemp);
EnsureConstStringRef(ArgTemp, Par);
EnsureConstStringRef(ArgTemp, Par, TASTExpr(ACall.Args.Items[I]), MDecl.Params);
ArgTemp := CoerceArg(ArgTemp, TASTExpr(ACall.Args.Items[I]), QbeTypeOf(Par.ResolvedType));
ArgLine := ArgLine + Format(', %s %s',
[QbeParamTypeOf(Par.ResolvedType), ArgTemp]);
@ -7647,7 +7699,7 @@ begin
begin
ArgTemp := EmitExpr(TASTExpr(ACall.Args.Items[I]));
ArgTemps.Add(ArgTemp);
EnsureConstStringRef(ArgTemp, Par);
EnsureConstStringRef(ArgTemp, Par, TASTExpr(ACall.Args.Items[I]), MDecl.Params);
ArgTemp := CoerceArg(ArgTemp, TASTExpr(ACall.Args.Items[I]), QbeTypeOf(Par.ResolvedType));
ArgLine := ArgLine + Format('%s %s', [QbeParamTypeOf(Par.ResolvedType), ArgTemp]);
end;
@ -9341,7 +9393,7 @@ begin
ArgTemp := EmitExpr(TASTExpr(FC.Args.Items[I]));
while ArgTemps.Count < I do ArgTemps.Add('');
ArgTemps.Add(ArgTemp);
EnsureConstStringRef(ArgTemp, Par);
EnsureConstStringRef(ArgTemp, Par, TASTExpr(FC.Args.Items[I]), MDecl.Params);
ArgTemp := CoerceArg(ArgTemp, TASTExpr(FC.Args.Items[I]), QbeTypeOf(Par.ResolvedType));
ArgLine := ArgLine + Format(', %s %s',
[QbeParamTypeOf(Par.ResolvedType), ArgTemp]);
@ -9413,7 +9465,7 @@ begin
begin
ArgTemp := EmitExpr(TASTExpr(FC.Args.Items[I]));
ArgTemps.Add(ArgTemp);
EnsureConstStringRef(ArgTemp, Par);
EnsureConstStringRef(ArgTemp, Par, TASTExpr(FC.Args.Items[I]), MDecl.Params);
ArgTemp := CoerceArg(ArgTemp, TASTExpr(FC.Args.Items[I]), QbeTypeOf(Par.ResolvedType));
ArgLine := ArgLine + Format('%s %s', [QbeParamTypeOf(Par.ResolvedType), ArgTemp]);
end;
@ -9603,7 +9655,7 @@ begin
begin
ArgTemp := EmitExpr(TASTExpr(MCallExpr.Args.Items[I]));
if Par.IsVarParam then ArgTemps.Add('') else ArgTemps.Add(ArgTemp);
EnsureConstStringRef(ArgTemp, Par);
EnsureConstStringRef(ArgTemp, Par, TASTExpr(MCallExpr.Args.Items[I]), MDecl.Params);
ArgTemp := CoerceArg(ArgTemp, TASTExpr(MCallExpr.Args.Items[I]),
QbeTypeOf(Par.ResolvedType));
ArgLine := ArgLine + Format(', %s %s',
@ -9772,7 +9824,7 @@ begin
begin
ArgTemp := EmitExpr(TASTExpr(MCallExpr.Args.Items[I]));
ArgTemps.Add(ArgTemp);
EnsureConstStringRef(ArgTemp, Par);
EnsureConstStringRef(ArgTemp, Par, TASTExpr(MCallExpr.Args.Items[I]), MDecl.Params);
ArgTemp := CoerceArg(ArgTemp, TASTExpr(MCallExpr.Args.Items[I]),
QbeTypeOf(Par.ResolvedType));
ArgLine := ArgLine + Format(', %s %s', [QbeParamTypeOf(Par.ResolvedType), ArgTemp]);

View file

@ -67,6 +67,7 @@ uses
cp.test.collections,
cp.test.stringlistfind,
cp.test.maphash,
cp.test.constarg,
cp.test.caseenum,
cp.test.sets,
cp.test.selfhosting,

View file

@ -0,0 +1,313 @@
{
Blaise - An Object Pascal Compiler
Copyright (c) 2026 Graeme Geldenhuys
SPDX-License-Identifier: Apache-2.0 WITH Swift-exception
Licensed under the Apache License v2.0 with Runtime Library Exception.
See LICENSE file in the project root for full license terms.
}
unit cp.test.constarg;
{ IR tests for shape-aware const-string argument handling (QBE backend).
Const string params skip the callee-side retain/release pair (5a5b5d4);
the caller protects the argument for the duration of the call. The
protection depends on the argument's shape:
borrowed — string literals and named string consts (immortal data):
NO AddRef/Release pair is emitted at all.
consume — function/method/getter returns (+1 owned temps): no
AddRef, ONE Release after the call consumes the temp
(previously these leaked).
pin — everything else, including plain local variables —
AddRef before the call, Release after. Locals would be
borrowable in principle, but eliding their pin exposes a
latent uninitialised-register miscompile (bugs.txt
2026-06-10); these tests pin the CURRENT contract and
should be revisited when local borrowing lands. }
interface
uses
Classes, SysUtils, blaise.testing, uStrCompat,
uLexer, uParser, uAST, uSymbolTable, uSemantic, blaise.codegen.qbe;
type
TConstArgTests = class(TTestCase)
private
function GenIR(const ASrc: string): string;
{ Extract the body of one emitted function (from 'function ... $Name('
to the closing brace) so ARC assertions are not polluted by other
functions' codegen. }
function FuncRegion(const AIR, AName: string): string;
published
procedure TestConstArg_ParamForward_Pins;
procedure TestConstArg_LocalVar_Pins;
procedure TestConstArg_Literal_NoPin;
procedure TestConstArg_GlobalVar_Pins;
procedure TestConstArg_OwnedReturn_ConsumeOnly;
procedure TestConstArg_Concat_Pins;
procedure TestConstArg_VarStringSibling_Pins;
end;
implementation
function TConstArgTests.GenIR(const ASrc: string): string;
var
L: TLexer;
P: TParser;
Prog: TProgram;
A: TSemanticAnalyser;
CG: TCodeGenQBE;
begin
L := TLexer.Create(ASrc);
P := TParser.Create(L);
try
Prog := P.Parse();
finally
P.Free(); L.Free();
end;
try
A := TSemanticAnalyser.Create();
try
A.Analyse(Prog);
finally
A.Free();
end;
CG := TCodeGenQBE.Create();
try
CG.Generate(Prog);
Result := CG.GetOutput();
finally
CG.Free();
end;
finally
Prog.Free();
end;
end;
function TConstArgTests.FuncRegion(const AIR, AName: string): string;
var
StartP, EndP: Integer;
Marker: string;
begin
Marker := '$' + AName + '(';
StartP := Pos(Marker, AIR);
AssertTrue('function ' + AName + ' present in IR', StartP >= 0);
EndP := StrPos('}', StrCopyTail(AIR, StartP));
AssertTrue('function ' + AName + ' closed', EndP >= 0);
Result := StrCopyFrom(AIR, StartP, EndP);
end;
procedure TConstArgTests.TestConstArg_ParamForward_Pins;
const
Src = '''
program P;
procedure Sink(const S: string);
begin
end;
procedure Caller(const T: string);
begin
Sink(T)
end;
var L: string;
begin
L := 'x';
Caller(L)
end.
''';
var
Region: string;
begin
{ Forwarding a const param to a const param would be borrowable, but
variable shapes currently pin (see unit comment). }
Region := FuncRegion(GenIR(Src), 'Caller');
AssertTrue('AddRef pins forwarded param', Pos('_StringAddRef', Region) >= 0);
AssertTrue('Release unpins forwarded param',
Pos('_StringRelease', Region) >= 0);
end;
procedure TConstArgTests.TestConstArg_LocalVar_Pins;
const
Src = '''
program P;
procedure Sink(const S: string);
begin
end;
function Mk: string;
begin
Result := IntToStr(7)
end;
procedure Caller;
var
L: string;
begin
L := Mk();
Sink(L)
end;
begin
Caller()
end.
''';
var
Region: string;
RelCount, I: Integer;
begin
{ L := Mk() consumes the owned return (no AddRef) but still releases L's
previous value; Sink(L) pins L (variable shape, see unit comment).
Releases in Caller: assignment release-old + call unpin + L's
scope-exit release. }
Region := FuncRegion(GenIR(Src), 'Caller');
AssertTrue('pin AddRef present', Pos('_StringAddRef', Region) >= 0);
RelCount := 0;
I := Pos('_StringRelease', Region);
while I >= 0 do
begin
RelCount := RelCount + 1;
I := PosEx('_StringRelease', Region, I + 1);
end;
AssertEquals('three Releases (assign old + call unpin + scope exit)',
3, RelCount);
end;
procedure TConstArgTests.TestConstArg_Literal_NoPin;
const
Src = '''
program P;
procedure Sink(const S: string);
begin
end;
procedure Caller;
begin
Sink('hello')
end;
begin
Caller()
end.
''';
var
Region: string;
begin
Region := FuncRegion(GenIR(Src), 'Caller');
AssertEquals('no AddRef for literal arg', -1, Pos('_StringAddRef', Region));
AssertEquals('no Release for literal arg', -1, Pos('_StringRelease', Region));
end;
procedure TConstArgTests.TestConstArg_GlobalVar_Pins;
const
Src = '''
program P;
var G: string;
procedure Sink(const S: string);
begin
end;
procedure Caller;
begin
Sink(G)
end;
begin
G := 'x';
Caller()
end.
''';
var
Region: string;
begin
{ A global can be reassigned by the callee (through its own name), which
would release the buffer the borrowed argument points at — must pin. }
Region := FuncRegion(GenIR(Src), 'Caller');
AssertTrue('AddRef pins global arg', Pos('_StringAddRef', Region) >= 0);
AssertTrue('Release unpins global arg', Pos('_StringRelease', Region) >= 0);
end;
procedure TConstArgTests.TestConstArg_OwnedReturn_ConsumeOnly;
const
Src = '''
program P;
procedure Sink(const S: string);
begin
end;
function Mk: string;
begin
Result := IntToStr(7)
end;
procedure Caller;
begin
Sink(Mk())
end;
begin
Caller()
end.
''';
var
Region: string;
begin
{ Mk() hands over a +1 temp; the post-call Release consumes it (this
used to leak — the pin pair netted to zero and nobody released). }
Region := FuncRegion(GenIR(Src), 'Caller');
AssertEquals('no AddRef for owned-return arg', -1,
Pos('_StringAddRef', Region));
AssertTrue('Release consumes the owned temp',
Pos('_StringRelease', Region) >= 0);
end;
procedure TConstArgTests.TestConstArg_Concat_Pins;
const
Src = '''
program P;
procedure Sink(const S: string);
begin
end;
procedure Caller(const T: string);
begin
Sink('a' + T)
end;
var L: string;
begin
L := 'x';
Caller(L)
end.
''';
var
Region: string;
begin
{ _StringConcat returns an rc=0 transient: the AddRef/Release pair both
protects it during the call and frees it afterwards. }
Region := FuncRegion(GenIR(Src), 'Caller');
AssertTrue('AddRef pins concat temp', Pos('_StringAddRef', Region) >= 0);
AssertTrue('Release frees concat temp', Pos('_StringRelease', Region) >= 0);
end;
procedure TConstArgTests.TestConstArg_VarStringSibling_Pins;
const
Src = '''
program P;
procedure Swapish(const A: string; var B: string);
begin
B := 'new'
end;
procedure Caller;
var
L: string;
begin
L := IntToStr(7);
Swapish(L, L)
end;
begin
Caller()
end.
''';
var
Region: string;
begin
{ B aliases L itself: the callee's write to B releases L's buffer while
A still borrows it — A must be pinned (variable shapes always pin). }
Region := FuncRegion(GenIR(Src), 'Caller');
AssertTrue('AddRef pins despite local shape',
Pos('_StringAddRef', Region) >= 0);
end;
initialization
RegisterTest(TConstArgTests);
end.

View file

@ -175,10 +175,10 @@ type
FHashCap: Integer; { power of two; 0 = index not built }
FHashUsed: Integer; { occupied slots (excludes duplicate keys) }
procedure Grow;
function Compare(S1: string; S2: string): Integer;
function FindSorted(S: string; var Idx: Integer): Boolean;
function HashOf(S: string): Integer;
function KeyEquals(S1: string; S2: string): Boolean;
function Compare(const S1: string; const S2: string): Integer;
function FindSorted(const S: string; var Idx: Integer): Boolean;
function HashOf(const S: string): Integer;
function KeyEquals(const S1: string; const S2: string): Boolean;
procedure HashInvalidate;
procedure HashInsertIdx(AIdx: Integer);
procedure HashRebuild;
@ -188,8 +188,8 @@ type
procedure Destroy;
function Add(S: string): Integer;
procedure AddObject(S: string; AObject: Pointer);
function Find(S: string; var Index: Integer): Boolean;
function IndexOf(S: string): Integer;
function Find(const S: string; var Index: Integer): Boolean;
function IndexOf(const S: string): Integer;
function Get(AIndex: Integer): string;
procedure Put(AIndex: Integer; S: string);
function GetObject(AIndex: Integer): Pointer;
@ -387,7 +387,7 @@ begin
Self.FCapacity := NewCap
end;
function TStringList.Compare(S1: string; S2: string): Integer;
function TStringList.Compare(const S1: string; const S2: string): Integer;
begin
if Self.FCaseSensitive then
Result := CompareStr(S1, S2)
@ -397,7 +397,7 @@ end;
{ Equality-only comparison for hash probing. Cheaper than Compare: both
RTL helpers reject on length before touching the bytes. }
function TStringList.KeyEquals(S1: string; S2: string): Boolean;
function TStringList.KeyEquals(const S1: string; const S2: string): Boolean;
begin
if Self.FCaseSensitive then
Result := S1 = S2
@ -407,7 +407,7 @@ end;
{ FNV-1a over the string bytes, folding A..Z to a..z when the list is
case-insensitive so equal-modulo-case keys land in the same bucket. }
function TStringList.HashOf(S: string): Integer;
function TStringList.HashOf(const S: string): Integer;
var
I: Integer;
C: Integer;
@ -504,7 +504,7 @@ begin
end;
end;
function TStringList.FindSorted(S: string; var Idx: Integer): Boolean;
function TStringList.FindSorted(const S: string; var Idx: Integer): Boolean;
var
Lo: Integer;
Hi: Integer;
@ -620,7 +620,7 @@ begin
ObjP^ := AObject
end;
function TStringList.Find(S: string; var Index: Integer): Boolean;
function TStringList.Find(const S: string; var Index: Integer): Boolean;
const
cHashThreshold = 16; { below this a linear scan beats building a table }
var
@ -676,7 +676,7 @@ begin
end
end;
function TStringList.IndexOf(S: string): Integer;
function TStringList.IndexOf(const S: string): Integer;
var
Idx: Integer;
begin