목록과 스트리밍
긴 목록과 스트리밍 텍스트
채팅 클라이언트는 아주 긴 목록에, 토큰 하나씩 도착하는 텍스트를 더한 것입니다. compose-rust에는 각각을 위한 레코드가 있습니다. 렌더러가 이끄는 윈도잉 목록, 그리고 새로 온 뒷부분만 싣는 덧붙이기입니다. 둘 다 지금 경계에서 동작합니다. 이것을 쓰는 작성 API는 1.0.0에 들어옵니다.
윈도잉 목록 구현
가상화된 목록은 트리 전체로 기술할 수 없습니다. 렌더러가 항목이 있다는 것을 알게 하려면, Host가 만들지도 않은 항목까지 기술해야 하기 때문입니다. 그래서 lazy 목록에서는 흐름이 뒤집힙니다.
- Host가 전체 항목 수와 항목마다 안정된 키를 알립니다.
- 렌더러가 보이는 범위를 정하고, 범위 요청 이벤트로 그 범위를 요청합니다.
- Host는 그 범위와 양쪽의 버퍼만큼만 하위 트리를 만들고, 그 밖은 만들지 않습니다.
항목마다 키가 실리고, 렌더러는 그것을 Compose LazyColumn 의 항목 키로 넘깁니다.
스크롤 위치와 항목의 정체성은 렌더러에, 데이터는 Host에 남습니다. 항목 10,000개에서 보이는 줄
20개와 버퍼 4를 요청하면 Host는 28개를 만듭니다. 비용은 목록이 아니라 창을 따릅니다. 테스트가
정확히 이것을 확인합니다.
LazyColumn, LazyRow, LazyGrid 가 이 약속을 함께
따릅니다. 0.0.1에서는 Runtime 이 요청을 RangeRequest 로 받아 레코드로
답합니다. 작성 API도 같은 레코드를 쓰므로, 그것으로 쓴 lazy 목록도 같은 방식으로 윈도잉됩니다
계획.
평범한 열
항목마다 자식 하나를 둔 ScrollColumn 은 맞는 방법이고, 화면에 들어가는 몇백 줄과
약간의 이전 기록에는 충분합니다. 다만 윈도잉하지 않으므로 항목마다 양쪽에 실제 노드가
생깁니다. 항목 하나를 바꾸면 그 항목의 변화만 나가므로, 이 방식의 비용은 갱신 경로가 아니라
노드 수입니다.
스트리밍 텍스트 구현
LLM 응답은 토큰 단위로 도착합니다. 토큰마다 Text 의 내용 전체를 바꾸면 매번
메시지 전체를 다시 보냅니다. 메시지는 길어지므로 전체 비용은 제곱으로 늘어납니다. compose-rust는
대신 새 뒷부분만 싣는 AppendText 레코드를 보냅니다.
Host가 합칩니다. 두 프레임 사이에 도착한 토큰은 노드마다 AppendText 하나로
합쳐져 다음 프레임에 나갑니다. 토큰 하나가 배치 하나를 차지하는 일은 없고, 버퍼를 재사용하므로
꾸준한 스트리밍은 할당하지 않습니다. Host에서 잰 값으로, 36 KB 메시지에서도 토큰당 배치는
64바이트 아래이고 스트리밍 프레임의 p99는 125 ns입니다.
// Queues a tail for the next frame. Repeated calls coalesce into one record
// per node and into a single frame request.
host.append_text(node_id, " token");
Host::append_text 는 프로토콜 노드 id를 받습니다. 애플리케이션 코드가 아니라
트리를 만드는 층을 위한 것입니다. Runtime 은 Batch::append_text_node
로 같은 레코드를 씁니다. 작성 API는 노드 id 없이 스트리밍을 쓰게 할 것입니다
계획.
워커 스레드의 일
도메인 작업은 UI 스레드에서 돌면 안 됩니다. 그 스레드가 UI 코드와 경계 전체도 돌리기 때문입니다. PTY 읽기, HTTP, LLM 스트리밍, 파일 I/O는 Host 워커 스레드의 몫입니다.
UI의 관점에서 워커가 할 일은 상태를 바꾸는 것뿐입니다. 렌더러를 깨우는 것은 내부의 일입니다.
request_frame_from_worker 는 어느 스레드에서든 프레임을 요청하고, 여러 요청은
Compose 프레임 시계 위의 프레임 하나로 합쳐집니다. 작성 API에서는 워커가 UI 스레드와 같은 상태
객체에 쓰고, 프레임은 Host가 대신 요청합니다. 애플리케이션 코드는 경계 함수를 부르지 않으며, 두
쪽 사이에 차오를 큐도 없습니다.
핸들러는 렌더러가 Host를 부른 호출 안에서 돕니다. 거기서 요청한 프레임은 그 호출이 돌아갈 때까지 미뤄지므로, 디스패치 도중에 렌더러에 다시 들어가는 일이 없습니다. 상태를 바꾸고 프레임이 오게 두십시오.
프레임 예산
목표는 따로 떨어진 숫자가 아니라 비교입니다. 같은 화면을 Kotlin과 Compose로 직접 쓴 것이 기준이고, compose-rust는 그보다 프레임 시간을 최대 10%까지만 더 쓸 수 있습니다. 120 Hz 화면의 8.33 ms 예산에 대해서입니다.
| 항목 | 예산(p99, 릴리스) |
|---|---|
| Host 작업: 핸들러, 갱신, 배치 인코딩 | 일반 상호작용 0.5 ms 이하, 스트리밍 프레임 1 ms 이하 |
| 경계 호출 한 번 | 데스크톱과 iOS 100 ns 이하, Android 200 ns 이하 |
| 렌더러의 배치 적용 | 레코드 100개당 0.3 ms 이하 |
| 입력에서 화면까지 | 기준과 같은 프레임 수: 지연 프레임이 더 없음 |
| 떨어진 프레임 | 초당 토큰 100개를 스트리밍하며 메시지 10,000개 대화를 스크롤해도 없음 |
배치 인코딩은 힙에 아무것도 할당하지 않습니다. 메모리는 추세로 잽니다. 같은 상호작용을 되풀이해도 늘면 안 되고, 늘면 누수나 동작을 멈춘 캐시로 보고 조사합니다.