Use local builders for rendered templates
This commit is contained in:
@@ -143,7 +143,9 @@ between artifact conversion chunks, around each message, and before publishing
|
|||||||
the complete prompt. It cannot interrupt template work already in progress and
|
the complete prompt. It cannot interrupt template work already in progress and
|
||||||
never publishes a partial prompt after observing cancellation. It validates and
|
never publishes a partial prompt after observing cancellation. It validates and
|
||||||
canonicalizes message roles before carrying roles, session IDs, and cache
|
canonicalizes message roles before carrying roles, session IDs, and cache
|
||||||
control into the rendered prompt. The
|
control into the rendered prompt. Message and session templates execute into
|
||||||
|
per-call string builders, so their rendered strings do not require a final
|
||||||
|
buffer-to-string copy. The
|
||||||
[renderer tests](../../internal/prompt/renderer_test.go) own rendering behavior.
|
[renderer tests](../../internal/prompt/renderer_test.go) own rendering behavior.
|
||||||
|
|
||||||
## Schemas And Output Validation
|
## Schemas And Output Validation
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Implement the remaining local work in the [performance roadmap](performance.md)
|
Implement the remaining local work in the [performance roadmap](performance.md)
|
||||||
in order. Each sequential stage is scoped for one Luna coding-agent prompt.
|
in order. Each sequential stage is scoped for one Luna coding-agent prompt.
|
||||||
Stages 1–2 are complete; Stages 3–14 remain pending. Update status and evidence
|
Stages 1–3 are complete; Stages 4–14 remain pending. Update status and evidence
|
||||||
as each work unit lands.
|
as each work unit lands.
|
||||||
The completed inspection plan is preserved at
|
The completed inspection plan is preserved at
|
||||||
`481e2d4:docs/roadmap/implementation.md` in Git history.
|
`481e2d4:docs/roadmap/implementation.md` in Git history.
|
||||||
@@ -150,6 +150,11 @@ family. Existing empty-output repair behavior remains unchanged.
|
|||||||
|
|
||||||
## Stage 3 — Render Messages And Sessions With Local String Builders
|
## Stage 3 — Render Messages And Sessions With Local String Builders
|
||||||
|
|
||||||
|
**Status: Complete (2026-10-09).** Message and session template execution now
|
||||||
|
uses per-call `strings.Builder` values. Exact rendering, cancellation, and
|
||||||
|
concurrent compiled-template behavior pass; benchmark evidence shows lower
|
||||||
|
allocation without a measurable retained-output increase.
|
||||||
|
|
||||||
**Outcome:** Template output becomes a string without a final buffer-to-string
|
**Outcome:** Template output becomes a string without a final buffer-to-string
|
||||||
copy.
|
copy.
|
||||||
|
|
||||||
@@ -623,6 +628,61 @@ Focused verification passed on the implementation commit:
|
|||||||
`go test -mod=readonly ./internal/validate ./internal/usecase`. The benchmark
|
`go test -mod=readonly ./internal/validate ./internal/usecase`. The benchmark
|
||||||
family above was rerun on that commit with the recorded command.
|
family above was rerun on that commit with the recorded command.
|
||||||
|
|
||||||
|
### Compiled-Rendering And Retained-Output Comparison
|
||||||
|
|
||||||
|
The maintained Stage 1 benchmark fixture was measured before the rendering
|
||||||
|
change at `dd89be3` (after the basic-validation change, which does not affect
|
||||||
|
rendering), then again after replacing the message and session buffers in the
|
||||||
|
working tree based on that revision. Setup and fixture contents are unchanged.
|
||||||
|
Environment: Linux/amd64, Intel Core i5-7260U @ 2.20 GHz, benchmark parallelism
|
||||||
|
4, Go 1.26.8, ConfigKit `v0.5.0`, JSON Schema `v6.0.2`, and backend modules
|
||||||
|
`v3.20260930.0`.
|
||||||
|
|
||||||
|
Command for both runs (five 500 ms samples per benchmark, without the race
|
||||||
|
detector):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GOTOOLCHAIN=local GOWORK=off GOPROXY=off GONOPROXY=none GOSUMDB=off \
|
||||||
|
go test -mod=readonly -run '^$' \
|
||||||
|
-bench '^(BenchmarkCompiledRendering|BenchmarkRetainedRenderedOutput)$' \
|
||||||
|
-benchmem -benchtime=500ms -count=5 ./internal/prompt
|
||||||
|
```
|
||||||
|
|
||||||
|
Times list all five `ns/op` samples in command order, followed by median and
|
||||||
|
range. Allocation figures are `B/op; allocs/op`.
|
||||||
|
|
||||||
|
| Compiled-render case | Before `ns/op` (median; range) | Before allocations | After `ns/op` (median; range) | After allocations |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| Small message | 2066, 1906, 2003, 2027, 2010 (2010; 1906–2066) | 1632; 20 | 1912, 2030, 1877, 2045, 1926 (1926; 1877–2045) | 1568; 20 |
|
||||||
|
| Artifact-backed 256 KiB message | 279116, 341635, 285472, 348345, 341764 (341635; 279116–348345) | 1050713–1050718; 25 | 165860, 239221, 255435, 223641, 214475 (223641; 165860–255435) | 788485–788489; 24 |
|
||||||
|
| Repeated across session and messages | 966012, 1073914, 1098700, 1154416, 1064160 (1073914; 966012–1154416) | 3946015–3946038; 74 | 778584, 777091, 888988, 739473, 802395 (778584; 739473–888988) | 2446632–2446651; 71–72 |
|
||||||
|
|
||||||
|
The retained-output workload holds four separately rendered 256 KiB results
|
||||||
|
and reports post-GC live-heap change over the same setup-only fixture. Its
|
||||||
|
allocation and retained-heap samples were:
|
||||||
|
|
||||||
|
| Measurement | Before | After |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ns/op` (median; range) | 331625, 333337, 343766, 302864, 329355 (331625; 302864–343766) | 244284, 239994, 238773, 239356, 264424 (239994; 238773–264424) |
|
||||||
|
| `B/op; allocs/op` | 1050696–1050708; 25 | 788486–788495; 24 |
|
||||||
|
| Retained heap (`retained-B`, median; range) | 1049744, 1046160, 1052768, 1044928, 1047264 (1047264; 1044928–1052768) | 1045824, 1054000, 1048288, 1047504, 1047168 (1047504; 1045824–1054000) |
|
||||||
|
|
||||||
|
The artifact-backed case removes about 256 KiB of allocation per render, and
|
||||||
|
the repeated case removes about 1.5 MiB; both also lose one allocation. Small
|
||||||
|
rendering changes by 64 bytes and 0 allocations. The retained-heap ranges
|
||||||
|
overlap, with medians differing by only 240 bytes for four 256 KiB outputs,
|
||||||
|
so the returned strings keep approximately the same live output storage rather
|
||||||
|
than retaining an additional builder buffer. This is expected: the builder's
|
||||||
|
backing storage is the returned string instead of being copied into a second
|
||||||
|
allocation. Measurements support retaining the simpler local-builder change
|
||||||
|
without a material retention trade-off; the existing per-render artifact-text
|
||||||
|
conversion and cache remain unchanged.
|
||||||
|
|
||||||
|
Focused verification passed: `go test -race -mod=readonly ./internal/prompt`,
|
||||||
|
including exact message/session output, cancellation, artifact-text lifecycle,
|
||||||
|
and a new concurrent-render case that verifies independent request variables,
|
||||||
|
artifact text, and session IDs against shared compiled templates.
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
No consumer-policy decisions remain open. File reservation and encoding error
|
No consumer-policy decisions remain open. File reservation and encoding error
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package prompt
|
package prompt
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -126,7 +125,7 @@ func (r *goRenderer) Render(ctx context.Context, definition *domain.PromptDefini
|
|||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var buf bytes.Buffer
|
var buf strings.Builder
|
||||||
executeErr := tmpl.Execute(&buf, vars)
|
executeErr := tmpl.Execute(&buf, vars)
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -235,7 +234,7 @@ func executeSessionTemplate(ctx context.Context, tmpl *template.Template, vars m
|
|||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
var buf bytes.Buffer
|
var buf strings.Builder
|
||||||
executeErr := tmpl.Execute(&buf, vars)
|
executeErr := tmpl.Execute(&buf, vars)
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
@@ -501,6 +504,75 @@ func TestGoRendererArtifactTextLifecycle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCompiledRendererKeepsConcurrentRequestsIndependent(t *testing.T) {
|
||||||
|
const (
|
||||||
|
promptID = "concurrent-render"
|
||||||
|
version = "v1"
|
||||||
|
)
|
||||||
|
sessionTemplate, err := CompileTemplate("session", "session-{{.request}}")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("compile session template: %v", err)
|
||||||
|
}
|
||||||
|
messageTemplate, err := CompileTemplate("message", `request={{.request}};document={{input "document"}}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("compile message template: %v", err)
|
||||||
|
}
|
||||||
|
renderer := NewCompiledRenderer(compiledRendererTestLookup{
|
||||||
|
id: promptID,
|
||||||
|
version: version,
|
||||||
|
templates: CompiledTemplates{
|
||||||
|
Session: sessionTemplate,
|
||||||
|
Messages: []*template.Template{messageTemplate},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
definition := &domain.PromptDefinition{
|
||||||
|
ID: promptID, Version: version, SessionID: "session-{{.request}}",
|
||||||
|
Inputs: []domain.PromptInput{{Name: "document"}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{{Role: "user", Content: `request={{.request}};document={{input "document"}}`}},
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestCount = 24
|
||||||
|
errs := make(chan error, requestCount)
|
||||||
|
var wait sync.WaitGroup
|
||||||
|
wait.Add(requestCount)
|
||||||
|
for i := 0; i < requestCount; i++ {
|
||||||
|
go func(i int) {
|
||||||
|
defer wait.Done()
|
||||||
|
request := fmt.Sprintf("request-%d", i)
|
||||||
|
document := fmt.Sprintf("document-%d", i)
|
||||||
|
rendered, err := renderer.Render(context.Background(), definition,
|
||||||
|
map[string]*domain.Artifact{"document": {Body: []byte(document)}},
|
||||||
|
map[string]string{"request": request},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
errs <- fmt.Errorf("render %s: %w", request, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rendered.SessionID != "session-"+request || len(rendered.Messages) != 1 ||
|
||||||
|
rendered.Messages[0].Content != "request="+request+";document="+document {
|
||||||
|
errs <- fmt.Errorf("render %s returned session %q and messages %#v", request, rendered.SessionID, rendered.Messages)
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wait.Wait()
|
||||||
|
close(errs)
|
||||||
|
for err := range errs {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type compiledRendererTestLookup struct {
|
||||||
|
id, version string
|
||||||
|
templates CompiledTemplates
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup compiledRendererTestLookup) GetCompiledTemplates(id, version string) (CompiledTemplates, bool) {
|
||||||
|
if id != lookup.id || version != lookup.version {
|
||||||
|
return CompiledTemplates{}, false
|
||||||
|
}
|
||||||
|
return lookup.templates, true
|
||||||
|
}
|
||||||
|
|
||||||
// BenchmarkGoRendererArtifactReferences measures NewGoRenderer's per-call
|
// BenchmarkGoRendererArtifactReferences measures NewGoRenderer's per-call
|
||||||
// template-parsing fallback rather than the captured compiled path.
|
// template-parsing fallback rather than the captured compiled path.
|
||||||
func BenchmarkGoRendererArtifactReferences(b *testing.B) {
|
func BenchmarkGoRendererArtifactReferences(b *testing.B) {
|
||||||
|
|||||||
Reference in New Issue
Block a user