Lulucat

Ανακατασκευή του pipeline απόδοσης του Lulucat Notes με το Kimi K3

Gaoge ZhangGaoge Zhang

Το pipeline πλακιδίων Core Graphics έγινε pipeline point-sprite Metal σε έξι μικρά βήματα, το καθένα επαληθευμένο σε πραγματικό iPad. Ο κώδικας γράφτηκε με pair-programming με το Kimi K3 στο Fireworks.

Ενημερώθηκε

Την προηγούμενη εβδομάδα, το σύρσιμο μιας επιλογής λάσο στο Lulucat Notes παρήγαγε ένα ορατό κύμα: μερικά πλακίδια οθόνης έδειχναν την επιλογή στη νέα της θέση, ενώ άλλα εξακολουθούσαν να δείχνουν την παλιά, στο ίδιο καρέ. Αντικαταστήσαμε ολόκληρο το pipeline απόδοσης — bitmap Core Graphics συν CATiledLayer — με Metal, σε έξι μικρά βήματα, το καθένα επαληθευμένο σε ένα πραγματικό iPad πριν ξεκινήσει το επόμενο.

Ο κώδικας γράφτηκε με pair-programming με το Kimi K3, το ανοιχτό μοντέλο της Moonshot που εκτελείται στο Fireworks. Ο άνθρωπος καθοδηγούσε, αποφάσιζε και δοκίμαζε· το μοντέλο έγραψε σχεδόν κάθε γραμμή.

Το pipeline

Το παλιό pipeline είχε δύο είδη σχεδίασης που σιγά σιγά απέκλιναν: οι πινελιές ψήνονταν σε ένα bitmap για φθηνή εμφάνιση και επανασχεδιάζονταν ως διανύσματα όποτε τα πλακίδια χρειάζονταν περισσότερη λεπτομέρεια. Το νέο pipeline έχει ακριβώς μία ιδέα: ό,τι μοιάζει με μελάνι είναι point sprite. Μια πινελιά στυλό, μια διαδρομή marker και ένα χτύπημα γόμας είναι το ίδιο vertex των 32 byte — θέση, διάμετρος, χρώμα — σχεδιασμένο από το ίδιο ζεύγος shader ως κύκλοι ραστεροποιημένοι στην GPU, σε απόσταση ενός σημείου μεταξύ τους κατά μήκος του μήκους τόξου της πινελιάς.

Τρία πάνελ της ίδιας καμπύλης πινελιάς: σημεία αφής εισόδου, κυκλικές σφραγίδες σε απόσταση κατά μήκος της καμπύλης ανά μήκος τόξου, και η συνθετοποιημένη συμπαγής πινελιά.

Σε απόσταση ενός σημείου, μια αλυσίδα κύκλων αποκλίνει από μια μαθηματικά τέλεια κάψουλα κατά περίπου 0.075 σημεία — ένα πέμπτο του pixel στην πυκνότητα του καμβά μας. Σε αντάλλαγμα, τρία εργαλεία ενοποιούνται σε μία διαδρομή κώδικα, και η GPU κάνει αυτό που κάνει καλύτερα.

Γύρω από αυτή την ιδέα η αρχιτεκτονική είναι απλή. Το δεσμευμένο μελάνι ζει σε μια μοναδική υφή 4096². Το UIScrollView επιβιώνει, υποβιβασμένο σε μια καθαρή μηχανή χειρονομιών: το contentOffset και το zoomScale του τροφοδοτούν ένα viewport uniform σε κάθε καρέ, οπότε το pan και το zoom δεν γράφουν τίποτα. Κάθε καρέ είναι πέντε σχεδιάσεις:

flowchart TB
    subgraph frame["Every frame: five draws"]
        direction TB
        paper["1 · paper blit"] --> ink["2 · committed ink"]
        ink --> live["3 · live stroke"]
        live --> sel["4 · selection"]
        sel --> dash["5 · lasso dashes"]
    end
    commit["stroke commit<br/>append stamps"] --> tex[("ink texture<br/>4096² render target")]
    replay["regional replay<br/>erase · delete · move · undo"] --> tex
    tex -. "sampled or re-drawn" .-> ink

Οι επεξεργασίες γράφουν την υφή απευθείας. Η δέσμευση μιας πινελιάς προσθέτει τις σφραγίδες της. Το σβήσιμο, η διαγραφή, η μετακίνηση και η αναίρεση επαναλαμβάνουν την επηρεασμένη περιοχή πίσω από ένα ορθογώνιο scissor: καθαρίζουν την περιοχή, επανασχεδιάζουν τις πινελιές που την τέμνουν και τελείωσε. Το μερικό σβήσιμο διατηρεί τη σημασιολογία ιδιοκτησίας από την προηγούμενη δημοσίευση — ένα σβήσιμο ανήκει στην πινελιά από την οποία αφαιρεί μελάνι — σχεδιάζοντας κάθε σβησμένη πινελιά σε μια scratch υφή, αφαιρώντας τις δικές της διαδρομές σβησίματος με destination-out blending (), και συνθέτοντας το αποτέλεσμα πίσω. Η απομόνωση της scratch υφής εμποδίζει τη γόμα να διαπερνά το χαρτί ή τις γειτονικές πινελιές.

Η επιλογή που ξεκίνησε όλα αυτά τώρα σχεδιάζεται και αυτή ως point sprites. Το σύρσιμό της ενημερώνει ένα uniform μετατόπισης. Μηδενικές εγγραφές υφής, μηδενικές ακυρώσεις πλακιδίων — το κύμα είναι δομικά εξαλειφμένο, όχι μετριασμένο.

Ο φύλακας: ένα pixel diff

Δεν διαγράψαμε τον renderer Core Graphics. Τον υποβαθμίσαμε σε μια offline υλοποίηση αναφοράς, και κάθε αλλαγή Metal πρέπει να περάσει μια σύγκριση pixel εναντίον του, πάνω σε πραγματικά δεδομένα πινελιάς καταγραφέντα στη συσκευή. Το κριτήριο αποδοχής δεν είναι «πανομοιότυπα pixel» — δύο σωστοί ραστεροποιητές διαφωνούν δικαιολογημένα κατά μερικά επίπεδα του γκρι κατά μήκος των anti-aliased ακμών. Η πύλη είναι δομική: κανένα λείπον μελάνι, καμία μετατόπιση, καμία χρωματική παρέκκλιση και καμία μεγάλη διαφορά πουθενά μακριά από το μελάνι.

Τρεις περικοπές των ίδιων χειρόγραφων σημειώσεων: αποδοσμένες από Core Graphics, αποδοσμένες από Metal point sprites, και η διαφορά pixel τους ενισχυμένη έξι φορές, που δείχνει μόνο αμυδρά περιγράμματα κατά μήκος των ακμών της πινελιάς.

Αυτό το harness έπιασε τέσσερα από τα πέντε bugs που συναντήσαμε κατασκευάζοντας τον offline renderer, όλα διαγνωσμένα περικόπτοντας την έξοδο σε επίπεδο pixel: μια αναντιστοιχία struct stride Swift/Metal (28 byte έναντι 32, επειδή το Metal στοιχίζει το float4 στα 16 — η οθόνη γέμισε με μπλοκ χρώματος), το [[point_size]] να μην είναι αναγνώσιμο ως varying στο fragment shader (κάθε σφραγίδα έβγαινε τετράγωνη), δύο render encoders να συνυπάρχουν σε ένα command buffer (όλα μαύρα), και μια αρχική σφραγίδα που έλειπε και άφηνε το πρώτο χιλιοστό των γρήγορων πινελιών αόρατο.

Έξι βήματα, όχι μία επανεγγραφή

Το σχέδιο μεταφοράς ήταν έξι βήματα που μπορούσαν να παραδοθούν ανεξάρτητα: offline renderer που περνά το pixel diff· display shell με μηδενική οπτική αλλαγή· live πινελιά στην GPU· επιλογή στην GPU· μεταλλάξεις που γράφουν την υφή απευθείας· επανασχεδίαση διανυσμάτων σε υψηλό zoom (ενσωματωμένη στο δεύτερο βήμα, επειδή το «μηδενική οπτική αλλαγή» το απαιτούσε). Κάθε βήμα τελείωνε με έναν άνθρωπο να γράφει, να σβήνει, να κάνει zoom και να σέρνει στο iPad Pro πάνω στο γραφείο — όχι με simulator και όχι με diff screenshot.

Η συσκευή έπιασε τρία bugs που κάθε αυτοματοποιημένος έλεγχος έχασε. Πάνω από 100% zoom, οι πινελιές σχεδιάζονταν δύο φορές — μαλακή υφή από κάτω, αιχμηρά sprites από πάνω — που διαβαζόταν ως μια αμυδρή θόλωση που ο άνθρωπος πρόσεξε σε δευτερόλεπτα. Η δέσμευση μιας πινελιάς τρεμόπαιζε για ένα καρέ επειδή το παλιό overlay έκανε cross-fade εκτός συγχρονισμού με την ενημέρωση της υφής. Και πάνω από 170% zoom, κάθε σημείωση εξαφανιζόταν: το ορθογώνιο culling ορατότητας χρησιμοποιούσε το contentOffset στον κλιμακωμένο χώρο συντεταγμένων του, οπότε απομακρυνόταν από τις πινελιές καθώς κάνατε zoom. Και οι τρεις ήταν διορθώσεις μίας γραμμής σε μία συνάρτηση, και καμία δεν υπήρχε σε κανένα τεστ που θα μπορούσαμε να είχαμε γράψει εκ των προτέρων, επειδή δεν ξέραμε να τις ψάξουμε. Για μια UI-first εφαρμογή καταναλωτή, αυτός είναι ο λόγος που ο άνθρωπος παραμένει στον βρόχο.

Πώς είναι να δουλεύεις με το Kimi K3

Γρήγορο, πρώτα απ’ όλα. Ο βρόχος «συζήτηση, εγγραφή, κατασκευή, εγκατάσταση, παρατήρηση» έτρεχε σε λεπτά, και ένα μοντέλο που απαντά γρήγορα αλλάζει πόσους βρόχους μπορείτε να αντέξετε μέσα σε μια μέρα.

Δεύτερο, δεν περιπλέκει υπερβολικά τα πράγματα. Αυτή η βάση κώδικα τρέχει σε ρητούς εσωτερικούς κανόνες — καμία σκαλωσιά backward-compatibility πριν την κυκλοφορία, πολυπλοκότητα μόνο όταν μια συσκευή την αποδεικνύει απαραίτητη — και το K3 τους ακολουθεί χωρίς να χρειάζεται υπενθύμιση. Δεν πρόσθεσε χωρικά ευρετήρια «για αργότερα», δεν τύλιξε κάθε κλήση σε αμυντικούς ελέγχους και δεν πρόσθεσε αφαιρέσεις χωρίς να χρειάζονται. Το να του δίνεις prompts μοιάζει με δουλειά δίπλα σε έναν ικανό συνάδελφο που έχει διαβάσει τους εσωτερικούς κανόνες και πραγματικά τους πιστεύει.

Τρίτο, δώστε του εργαλεία και τα χρησιμοποιεί πρόθυμα. Συνδέσαμε εργαλεία εικόνας — προβολή, περικοπή σε περιοχή pixel, αναπροσαρμογή μεγέθους — και το μοντέλο άρχισε προληπτικά να περικόπτει τη δική του έξοδο renderer για να διαγνώσει τα πέντε bugs του harness παραπάνω. Η ύπαρξη του εργαλείου τού θύμισε να κοιτάξει.

Το άλλο μισό: το K3 έγραψε τα περισσότερα bugs σε αυτή την ιστορία, συμπεριλαμβανομένου εκείνου του χώρου συντεταγμένων που έκανε τις σημειώσεις να εξαφανίζονται. Τα όριά του είναι πραγματικά. Αυτό που έκανε τη δουλειά ασφαλή δεν ήταν ποτέ το να έχει δίκιο το μοντέλο· ήταν το harness να πιάνει την παρέκκλιση απόδοσης και ο άνθρωπος να πιάνει την αίσθηση. Και όμως, μέρα με τη μέρα, δεν μπορούσα αξιόπιστα να το ξεχωρίσω από τα κλειστά μοντέλα πρώτης γραμμής που επίσης χρησιμοποιούμε — συστήματα κλάσης Opus. Σε ορισμένους άξονες ήταν ξεκάθαρα καλύτερο: πιο γρήγορο, και πολύ λιγότερο επιρρεπές στο να γεμίζει τη βάση κώδικα με αμυντικό σχεδιασμό.

Πώς θέλουμε να χτίζουμε από εδώ και πέρα

Έχουμε τελειώσει με την agentic ανάπτυξη μεγάλου spec — το στυλ όπου δίνετε σε ένα μοντέλο μια μεγάλη προδιαγραφή και αποδέχεστε ό,τι καταλήξει. Το πρόβλημα δεν είναι ο κακός κώδικας· είναι κώδικας που δεν καταλαβαίνει κανείς.

Αυτό που λειτούργησε εδώ, και αυτό που θα κρατήσουμε: μικρά βήματα, το καθένα συζητημένο πριν ξεκινήσει, το καθένα κατανοητό από τον άνθρωπο πριν χτιστεί, το καθένα επαληθευμένο στη συσκευή που θα ζει πάνω του. Η δουλειά του μοντέλου είναι να είναι γρήγορο, ακριβές και ειλικρινές για την αβεβαιότητα. Η δουλειά του ανθρώπου είναι η κρίση, το γούστο και η e2e επαλήθευση — ειδικά για UI-first λογισμικό καταναλωτή, όπου το spec δεν μπορεί να περιγράψει πώς μοιάζει το «σωστό». Το Kimi K3 στο Fireworks αποδεικνύεται καλά διαμορφωμένο ακριβώς για αυτόν τον βρόχο: αρκετά γρήγορο για να κρατά τον βρόχο σφιχτό, αρκετά έξυπνο για να κρατά τα βήματα μικρά και καθαρά.

Το κύμα έφυγε, το pipeline είναι μία ιδέα αντί για δύο, και η διαδικασία που μας έφερε εδώ μένει.