Skip to content

Commit 5464527

Browse files
author
gluck
committed
Merge perf/zero-copy-rows: a row on its way to the wire is a window, not a copy
The last term linear in conversation length. decodeRecord copied every message out of the stored array on every send; the elements are windows onto a frame that is already on the heap. ALIAS ON THE WAY TO THE WIRE. COPY ON THE WAY INTO THE CACHE. AssembleBody/streamed (COLD log) B/op -16.2% allocs -16.0% sec -15.7% live heap while writing slice 2.3-4.3MiB streamed 0.94MiB flat Two instruments were measuring nothing and were fixed first: the benchmark and the cache test both read a log the same process had written, which the tree cache serves resident without decoding a record.
2 parents 290eddf + 8e33aa7 commit 5464527

5 files changed

Lines changed: 349 additions & 20 deletions

File tree

‎internal/provider/anthropic/assemble_bench_test.go‎

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,6 @@ func TestStreamedAssemblyDoesNotRetainTheConversation(t *testing.T) {
8080
})
8181
a := &Anthropic{Model: "claude-opus-5", ReminderRenderer: "tag"}
8282

83-
// The store's own cache holds what this daemon appended, which is neither
84-
// path's doing and is bounded by the translation budget. It is measured
85-
// once and subtracted, so what remains is what THE ASSEMBLY retains.
86-
base := liveHeap()
87-
8883
sliceLive := func() uint64 {
8984
perMessage, lts := provider.CollectRows(provider.TranslationRows(rows, 0))
9085
req, err := a.projectMessagesWithLTs(oneRowEach(perMessage), lts, snap, nil, 4096, false, "claude-opus-5")
@@ -104,15 +99,22 @@ func TestStreamedAssemblyDoesNotRetainTheConversation(t *testing.T) {
10499
return w.live
105100
}()
106101

102+
// NO BASELINE, AND THAT IS THE POINT. Subtracting a floor measured before
103+
// the runs made this test flaky: the floor includes the fixture's own
104+
// garbage, the heap falls below it once that is collected, and an
105+
// unsigned subtraction then reports zero held for BOTH paths -- which
106+
// passes or fails depending on the collector's mood.
107+
//
108+
// The two runs are measured the same way in the same process, so their
109+
// DIFFERENCE is the conversation and nothing else. The slice assembler is
110+
// holding a body's worth that the streamed one is not.
107111
body := bodyBytes(t, a, rows, snap)
108-
sliceHeld := sliceLive - base
109-
streamedHeld := streamedLive - base
110-
t.Logf("held while writing: slice=%d KiB streamed=%d KiB (body=%d KiB, base=%d KiB)",
111-
sliceHeld>>10, streamedHeld>>10, body>>10, base>>10)
112-
require.Less(t, streamedHeld, sliceHeld/2,
113-
"the streamed assembler must not hold the conversation the slice one held")
114-
require.Less(t, streamedHeld, body/2,
115-
"a streamed body must not retain a body's worth of anything")
112+
t.Logf("live heap while writing: slice=%d KiB streamed=%d KiB (body=%d KiB)",
113+
sliceLive>>10, streamedLive>>10, body>>10)
114+
require.Greater(t, sliceLive, streamedLive,
115+
"the slice assembler must hold more than the streamed one")
116+
require.GreaterOrEqual(t, sliceLive-streamedLive, body/2,
117+
"the gap between them should be most of a body: the conversation the slice path holds")
116118
}
117119

118120
// liveHeap is the live heap after a collection: the floor a measurement is
@@ -181,11 +183,18 @@ func setPatch(kv map[string]string) message.Patch {
181183
// benchRowLog is a translator channel in a real store, holding a plausible
182184
// turn shape: a user message, an assistant message with a tool call, and its
183185
// result.
186+
//
187+
// IT REOPENS THE BACKEND BEFORE HANDING THE LOG BACK, and that is the whole
188+
// difference between measuring something and measuring nothing. An append
189+
// SEEDS the tree cache, so a log read in the process that wrote it is served
190+
// resident and never decodes a single record -- which is not the case that
191+
// costs anything. The rows a real send pays for are the ones written before
192+
// the daemon started: cold, and decoded from the frame every time.
184193
func benchRowLog(tb testing.TB, n int) store.Log[[]json.RawMessage] {
185194
tb.Helper()
186-
be, err := store.NewXwalBackend(tb.TempDir(), 0)
195+
root := tb.TempDir()
196+
be, err := store.NewXwalBackend(root, 0)
187197
require.NoError(tb, err)
188-
tb.Cleanup(func() { be.Close() })
189198
outfit, err := be.CreateOutfit("l", setPatch(map[string]string{"system.model": "m"}))
190199
require.NoError(tb, err)
191200
aria, _, err := be.ForkWith(outfit, 0, setPatch(map[string]string{"system.cwd": "/tmp"}))
@@ -211,7 +220,14 @@ func benchRowLog(tb testing.TB, n int) store.Log[[]json.RawMessage] {
211220
})
212221
require.NoError(tb, err)
213222
}
214-
return rows
223+
require.NoError(tb, be.Close())
224+
225+
cold, err := store.NewXwalBackend(root, 0)
226+
require.NoError(tb, err)
227+
tb.Cleanup(func() { cold.Close() })
228+
coldRows, err := cold.OpenTranslator(aria, "anthropic")
229+
require.NoError(tb, err)
230+
return coldRows
215231
}
216232

217233
func padding(i int) string {

‎internal/store/rowsplit.go‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package store
2+
3+
import (
4+
"encoding/json"
5+
6+
"github.com/jack-work/figaro/internal/store/xwal"
7+
)
8+
9+
// Rows without the copy.
10+
//
11+
// A translator record holds a JSON ARRAY of wire messages, and decoding it
12+
// with encoding/json costs one allocation and one memcpy PER MESSAGE:
13+
// json.RawMessage.UnmarshalJSON is `append(m[0:0], data...)`, so a 2KB row
14+
// becomes a 2KB copy that lives until the request ends. Multiplied by the
15+
// conversation, that was the last term still linear in history -- roughly
16+
// 2.6KB and 24 allocations per row per send, measured.
17+
//
18+
// The array is already on the heap, whole, in r.Payload. Its elements are
19+
// WINDOWS onto it. Splitting rather than decoding turns N copies into N slice
20+
// headers.
21+
//
22+
// # THE RULE, AND IT IS NOT NEGOTIABLE
23+
//
24+
// ALIAS ON THE WAY TO THE WIRE. COPY ON THE WAY INTO THE CACHE.
25+
//
26+
// An aliased row is a view into the frame the segment handed back. That is
27+
// safe -- the codec allocates a fresh buffer per read (segment/codec.go:
28+
// `buf := make([]byte, length)`), nothing is mmap'd, nothing is reused, and no
29+
// caller writes through a row -- but it PINS the frame for as long as the row
30+
// lives, and it makes the row's apparent size (len) smaller than the memory it
31+
// holds.
32+
//
33+
// For a row on its way to the request body that is free: it lives for one
34+
// write and is dropped, so nothing is pinned that was not already resident.
35+
//
36+
// For a row on its way INTO THE TREE CACHE it is a lie. The cache sizes an
37+
// entry as the sum of its rows' lengths (transEntrySize), so an entry whose
38+
// rows are windows onto a larger frame reports less than it holds, and the
39+
// budget stops bounding what it believes it is bounding. THAT IS EXACTLY THE
40+
// FAILURE eeec2bc0 WAS WRITTEN ABOUT -- "it held SLICES RETURNED BY THE LOG,
41+
// pinning payloads the window believed it had evicted" -- and re-introducing
42+
// it here, one layer down, would be the same bug wearing a different hat.
43+
//
44+
// So the two decoders are separate functions with separate call sites:
45+
// ScanRange aliases, everything else copies. Enforced by
46+
// TestCacheSeededRowsDoNotAliasTheirFrame.
47+
48+
// splitJSONArray splits a JSON array into its elements WITHOUT COPYING: every
49+
// returned row is a window onto b. ok is false for anything that is not an
50+
// array, which sends the caller back to encoding/json.
51+
func splitJSONArray(b []byte) ([]json.RawMessage, bool) {
52+
i := skipJSONSpace(b, 0)
53+
if i >= len(b) || b[i] != '[' {
54+
return nil, false
55+
}
56+
i = skipJSONSpace(b, i+1)
57+
if i < len(b) && b[i] == ']' {
58+
if skipJSONSpace(b, i+1) != len(b) {
59+
return nil, false
60+
}
61+
// An empty array is an empty slice, not a nil one: encoding/json
62+
// makes that distinction and this function answers as it does.
63+
return []json.RawMessage{}, true
64+
}
65+
var out []json.RawMessage
66+
for {
67+
start := i
68+
end, ok := xwal.JSONValueEnd(b, i)
69+
if !ok || end <= start {
70+
return nil, false
71+
}
72+
// Three-index slice: a row may not grow into its neighbour by append.
73+
out = append(out, json.RawMessage(b[start:end:end]))
74+
i = skipJSONSpace(b, end)
75+
if i >= len(b) {
76+
return nil, false
77+
}
78+
switch b[i] {
79+
case ',':
80+
i = skipJSONSpace(b, i+1)
81+
if i >= len(b) || b[i] == ']' {
82+
return nil, false // a trailing comma is not our shape
83+
}
84+
case ']':
85+
if skipJSONSpace(b, i+1) != len(b) {
86+
return nil, false
87+
}
88+
return out, true
89+
default:
90+
return nil, false
91+
}
92+
}
93+
}
94+
95+
func skipJSONSpace(b []byte, i int) int {
96+
for i < len(b) {
97+
switch b[i] {
98+
case ' ', '\t', '\n', '\r':
99+
i++
100+
default:
101+
return i
102+
}
103+
}
104+
return i
105+
}
106+
107+
// aliasRows reports the rows of a payload as windows onto it, for a T that is
108+
// []json.RawMessage and nothing else. ok is false for every other T and for a
109+
// payload the splitter does not recognise, and the caller decodes normally.
110+
func aliasRows[T any](payload []byte, v *T) bool {
111+
rows, ok := any(v).(*[]json.RawMessage)
112+
if !ok {
113+
return false
114+
}
115+
parts, ok := splitJSONArray(payload)
116+
if !ok {
117+
return false
118+
}
119+
*rows = parts
120+
return true
121+
}

‎internal/store/rowsplit_test.go‎

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package store
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"strings"
7+
"testing"
8+
"unsafe"
9+
10+
"github.com/stretchr/testify/require"
11+
12+
"github.com/jack-work/figaro/internal/store/xwal"
13+
)
14+
15+
// aliases reports whether row is a window onto payload rather than a copy.
16+
// It compares the ADDRESSES of the backing arrays, which is the only honest
17+
// way to ask: equal bytes prove nothing.
18+
func aliases(payload []byte, row []byte) bool {
19+
if len(payload) == 0 || len(row) == 0 {
20+
return false
21+
}
22+
base := uintptr(unsafe.Pointer(&payload[0]))
23+
at := uintptr(unsafe.Pointer(&row[0]))
24+
return at >= base && at < base+uintptr(len(payload))
25+
}
26+
27+
// The splitter must agree with encoding/json on every payload the store can
28+
// hold, and disagree with it about nothing but where the bytes live.
29+
func TestSplitJSONArrayMatchesEncodingJSON(t *testing.T) {
30+
cases := []string{
31+
`[]`,
32+
`[{"role":"user"}]`,
33+
`[{"role":"user"},{"role":"assistant"}]`,
34+
`[{"a":[1,2,{"b":"]"}]},{"c":"}"}]`,
35+
`[{"text":"a \" quote and a \\\\ backslash"}]`,
36+
`[{"text":"bracket ] inside a string"},{"text":"brace } inside"}]`,
37+
`[{"text":"\u00e9\u005d"}]`,
38+
` [ {"a":1} , {"b":2} ] `,
39+
`[1,2,3]`,
40+
`[true,false,null]`,
41+
`[{"nested":{"deep":{"deeper":[{"x":1}]}}}]`,
42+
`[{"text":"newline\nand\ttab"}]`,
43+
`[""]`,
44+
`["]","[",","]`,
45+
}
46+
for _, in := range cases {
47+
t.Run(in, func(t *testing.T) {
48+
// ONE conversion, used for both. []byte(s) allocates a fresh
49+
// array every time it is written, so converting twice compares
50+
// two unrelated buffers -- which reported copies that were not
51+
// copies, and could just as easily have reported an alias that
52+
// was not one, if the two happened to land adjacent.
53+
payload := []byte(in)
54+
var want []json.RawMessage
55+
require.NoError(t, json.Unmarshal(payload, &want), "fixture is not valid JSON")
56+
57+
got, ok := splitJSONArray(payload)
58+
require.True(t, ok, "splitter refused a payload encoding/json accepted")
59+
require.Equal(t, len(want), len(got))
60+
for i := range want {
61+
require.JSONEq(t, string(want[i]), string(got[i]), "element %d", i)
62+
}
63+
if len(got) > 0 && len(got[0]) > 0 {
64+
require.True(t, aliases(payload, got[0]),
65+
"the split element is a copy: the whole point is that it is not")
66+
}
67+
})
68+
}
69+
}
70+
71+
// Anything that is not an array of ours falls back to encoding/json rather
72+
// than being guessed at.
73+
func TestSplitJSONArrayRefusesWhatItCannotRead(t *testing.T) {
74+
for _, in := range []string{
75+
`null`, `{"not":"an array"}`, `[`, `[{"a":1}`, `[{"a":1},]`, `[,]`,
76+
`[{"a":1}] trailing`, ``, `"a string"`, `[{"a":1}][{"b":2}]`,
77+
} {
78+
t.Run(in, func(t *testing.T) {
79+
_, ok := splitJSONArray([]byte(in))
80+
require.False(t, ok, "the splitter accepted something it should have refused")
81+
})
82+
}
83+
}
84+
85+
// A row on its way to the wire is a window onto the frame.
86+
func TestScannedRowsAliasTheirFrame(t *testing.T) {
87+
payload := []byte(`[{"role":"user","content":"hello"},{"role":"assistant","content":"hi"}]`)
88+
rec := xwal.Record{ChannelLT: 1, MainLT: 1, Payload: payload}
89+
90+
aliased, ok := decodeRecordAliased[[]json.RawMessage](rec)
91+
require.True(t, ok)
92+
require.Len(t, aliased.Payload, 2)
93+
for i, row := range aliased.Payload {
94+
require.True(t, aliases(payload, row), "row %d was copied", i)
95+
}
96+
97+
owned, ok := decodeRecord[[]json.RawMessage](rec)
98+
require.True(t, ok)
99+
require.Len(t, owned.Payload, 2)
100+
for i, row := range owned.Payload {
101+
require.False(t, aliases(payload, row), "row %d aliases the frame it must own", i)
102+
require.JSONEq(t, string(aliased.Payload[i]), string(row))
103+
}
104+
}
105+
106+
// THE RULE, ENFORCED: alias on the way to the wire, copy on the way into the
107+
// cache.
108+
//
109+
// A cached entry is sized as the sum of its rows' lengths. If those rows were
110+
// windows onto a bigger frame, the entry would report less than it holds and
111+
// the budget would stop bounding what it believes it bounds -- the same
112+
// failure eeec2bc0 was written about, one layer down. So a row that reaches
113+
// the cache must own its bytes, and this test reaches into the cache to check.
114+
func TestCacheSeededRowsDoNotAliasTheirFrame(t *testing.T) {
115+
root := t.TempDir()
116+
b, err := NewXwalBackend(root, 0)
117+
require.NoError(t, err)
118+
119+
outfit, err := b.CreateOutfit("l", setPatch(map[string]string{"system.model": "m"}))
120+
require.NoError(t, err)
121+
aria, _, err := b.ForkWith(outfit, 0, setPatch(map[string]string{"system.cwd": "/tmp"}))
122+
require.NoError(t, err)
123+
rows, err := b.OpenTranslator(aria, "anthropic")
124+
require.NoError(t, err)
125+
126+
big := strings.Repeat("x", 4096)
127+
for i := 0; i < 8; i++ {
128+
_, err := rows.Append(Entry[[]json.RawMessage]{
129+
FigaroLT: uint64(i + 1),
130+
Payload: []json.RawMessage{
131+
json.RawMessage(fmt.Sprintf(`{"role":"user","content":%q}`, big)),
132+
json.RawMessage(fmt.Sprintf(`{"role":"assistant","content":%q}`, big)),
133+
},
134+
})
135+
require.NoError(t, err)
136+
}
137+
138+
// A COLD BACKEND, OR THIS TEST PROVES NOTHING. An append seeds the tree
139+
// cache with the payload the WRITER passed -- never a decoded frame -- so
140+
// a read on the same process is served from memory and the decode path
141+
// under test never runs. It did not, and the test passed anyway against a
142+
// deliberately broken decoder. Reopening is what makes the read cold.
143+
require.NoError(t, b.Close())
144+
cold, err := NewXwalBackend(root, 0)
145+
require.NoError(t, err)
146+
defer cold.Close()
147+
coldRows, err := cold.OpenTranslator(aria, "anthropic")
148+
require.NoError(t, err)
149+
150+
read := coldRows.Read()
151+
require.Len(t, read, 8)
152+
153+
// TWO ROWS OF ONE RECORD ARE THE TEST. In the frame they are separated
154+
// by exactly one comma, so a WINDOW onto that frame puts row 1 at
155+
// &row0 + len(row0) + 1. A copy cannot land there except by an accident
156+
// no allocator will hand you twice at this size.
157+
//
158+
// (cap(row) != len(row) is NOT the test: json.RawMessage decodes with
159+
// append, which rounds up to a size class, so an owned 4124-byte row has
160+
// capacity 4864 and the naive check calls it a window.)
161+
for i, e := range read {
162+
require.Len(t, e.Payload, 2)
163+
first, second := e.Payload[0], e.Payload[1]
164+
gap := uintptr(unsafe.Pointer(&second[0])) - uintptr(unsafe.Pointer(&first[0]))
165+
require.NotEqual(t, uintptr(len(first)+1), gap,
166+
"entry %d: the two rows are contiguous in one frame, so the cache holds windows", i)
167+
}
168+
}

‎internal/store/xwal/xwal.go‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2475,6 +2475,11 @@ func fastDecodeFrame(f []byte) (uint64, []byte, []byte, int64, bool) {
24752475
return mainLT, payload, f[i:end], ts, true
24762476
}
24772477

2478+
// JSONValueEnd is exported so the store's row splitter uses THIS scanner
2479+
// rather than growing a second one. A JSON grammar implemented twice is a
2480+
// JSON grammar that disagrees with itself on the row that matters.
2481+
func JSONValueEnd(b []byte, start int) (int, bool) { return jsonValueEnd(b, start) }
2482+
24782483
func jsonValueEnd(b []byte, start int) (int, bool) {
24792484
if start >= len(b) {
24802485
return 0, false
@@ -2517,8 +2522,12 @@ func jsonValueEnd(b []byte, start int) (int, bool) {
25172522
}
25182523
return 0, false
25192524
default:
2525+
// A bare scalar ends at the first structural byte that can follow it.
2526+
// ']' belongs in that set: it cannot occur INSIDE a number, true,
2527+
// false or null, and without it an array's last scalar element runs
2528+
// off the end of the array.
25202529
for i := start; i < len(b); i++ {
2521-
if b[i] == ',' || b[i] == '}' {
2530+
if b[i] == ',' || b[i] == '}' || b[i] == ']' {
25222531
return i, i > start
25232532
}
25242533
}

0 commit comments

Comments
 (0)