HyperAI
Back to Headlines

Zig ermöglicht effiziente Code-Optimierungen und Metaprogrammierung.

vor 18 Tagen

Optimierungen mit Zig | alloc.dev 2025-06-07 Vorsicht vor dem Turing-Sumpf, in dem alles möglich ist, aber nichts von Interesse leicht ist. – Alan Perlis, 1982 Was interessiert Sie? Viele Dinge, sicherlich. Eines der Themen, die mich ständig faszinieren, ist Programmoptimierung. Ob es darum geht, die größte Fibonacci-Zahl in einer Sekunde zu berechnen, oder die schnellste Finanztransaktionsdatenbank aller Zeiten zu erstellen, oder sogar etwas in Rust umzuschreiben – Optimierung kann sehr belohnend sein. Optimierung trennt Langsamheit von Geschwindigkeit. Technologische Fortschritte formen unsere Programme, aber sie eliminieren das Bedürfnis nach Optimierung nicht. Gut optimierte Programme sparen Geld, ermöglichen höhere Skalierungsmöglichkeiten und bewahren Systeme in ihrer Einfachheit. Wollen Sie lieber Tausende ausgeben, um schlechtes Code auf skalierender Cloud-Infrastruktur zu laufen, oder besserer Code schreiben, der Ihnen die Verwendung weniger Server mit reduzierter Latenz und Kosten ermöglicht? In diesem Artikel möchte ich den Konzept der Low-Level-Optimierung erklären und warum Zig dafür besonders gut geeignet ist. Wenn Sie das Lesen genießen, überlegen Sie bitte, mich zu unterstützen. Vertrauen Sie dem Compiler? Einige sagen: „Vertrauen Sie dem Compiler, er weiß am besten.“ Für die meisten Low-Level-Situationen hat der Compiler tatsächlich eine Menge gelernt. Mehr Systemressourcen und Fortschritte in IR-Transformationen haben es Compiler-Backends wie LLVM ermöglicht, beeindruckende Ergebnisse zu erzielen. Compiler sind komplexe Wesen. Selbst die besten Optimierungsbackends erzeugen manchmal suboptimalen Code. State-of-the-Art-Compiler brechen beispielsweise Sprachspezifikationen (Clang nimmt an, dass alle Schleifen ohne Seiteneffekte terminieren) und wir müssen sicherstellen, dass der Compiler korrekt arbeitet. In Low-Level-Sprachen können wir unser Code massieren, damit der Compiler bestimmte Transformationen anwendet. In anderen Fällen müssen wir entdecken, dass der Compiler keine optimalen Transformationen anwendet und dann Inline-Assembliercode schreiben, um das letzte bisschen Leistung herauszuholen. Was ist mit High-Level-Code? Außer in Nischenfällen können Compiler unsere Algorithmen und Paradigmen nicht ändern. Ihr Handlungsspielraum ist relativ eng. Wo positioned sich Zig? Ich liebe Zig wegen seiner Verbosity. Dank dieser Verbosity ist es einfacher, performantere Programme als in den meisten anderen Sprachen zu schreiben. Mit Zigs eingebauten Funktionen, nicht optionalen Pointern, unreachable Keyword, gut gewähltem illegalen Verhalten und ausgezeichneter comptime-Unterstützung wird LLVM praktisch mit Informationen über unseren Code gefüttert. Es sind jedoch nicht nur Regenbogen und Sonnenschein. Es gibt Tradeoffs. Rosts Speichermodell ermöglicht es dem Compiler, immer anzunehmen, dass Funktionsargumente nicht aliasen. Dies muss in Zig manuell spezifiziert werden. Wenn der Compiler nicht erkennen kann, dass Ihre Zig-Funktion immer mit nicht aliasenden Argumenten aufgerufen wird, werden Rost-Funktionen die nicht annotierten Zig-Funktionen übertreffen. Wenn wir gut annotierte LLVM-IR als einzigen Maßstab für die Optimierungsfähigkeit einer Sprache nehmen, dann macht Zig einen guten Job. Das ist jedoch nicht alles, was Zig bietet. Zigs wahres Optimierungspotential liegt in der Compile-Time-Execution. Was ist comptime? Zigs comptime dreht sich um Codegenerierung. Möchten Sie eine Konstante in Ihrem Code verwenden? Sie können sie zur Compile-Time generieren, und ihr Wert wird in das produzierte Binärdatei eingebettet. Möchten Sie vermeiden, dieselbe Hashmap für jeden Datentyp neu zu schreiben? Comptime hat Ihren Rücken freigestellt. Haben Sie Daten, die zur Compile-Time bekannt sind, und möchten, dass der Optimizer Code damit elidiert? Ja, das können Sie mit comptime tun. Zigs comptime ist ein Beispiel für Metaprogrammierung. Wie unterscheidet sich dies von Makros? Es ist ziemlich nuanciert. Der Zweck von comptime ist im Grunde genauso wie bei Makros. Einige Makros modificieren den Rohcode Ihres Programms, andere ändern den AST direkt. Dies ermöglicht es Makros, Code spezifisch für die Typen und Werte Ihrer Daten zu inline. In Zig ist comptime-Code einfach regulärer Code, der zur Compile-Time ausgeführt wird. Er kann keine Seiteneffekte haben (wie Netzwerki/O) und die emulierte Maschine wird dem Kompilierungstarget entsprechen. Der Grund, warum Zigs comptime so gut zu Makros passt, ist zweifach. Erstens kann fast jeder Zig-Code zur Compile-Time mit comptime ausgeführt werden. Zweitens können zur Compile-Time alle Typen inspiziert, reflektiert und generiert werden. So werden Generics in Zig implementiert. Die Flexibilität von Zigs comptime hat zu einigen schönen Verbesserungen in anderen Programmiersprachen geführt. Beispielsweise hat Rust das "crabtime"-Paket, das mehr Flexibilität und Power als Standard-Rust-Makros bietet. Ich glaube, ein Vorteil von comptime gegenüber aktuellen Alternativen liegt darin, wie nahtlos comptime in die Zig-Sprache integriert ist. Anders als bei C++'s constexpr müssen Sie keine neue "Sprache" lernen, um comptime zu verwenden. C++ verbessert sich, hat aber noch einen langen Weg vor sich, wenn es hofft, Zig in diesem Bereich zu konkurrieren. Kann Zigs comptime alles tun, was Makros können? Nein. Token-Pasting-Makros haben kein Pendant in Zigs comptime. Zig ist so gestaltet, dass es einfach zu lesen ist, und Makros, die Variablen in unabhängigen Gültigkeitsbereichen modifizieren oder erstellen, passen einfach nicht. Makros können andere Makros definieren, den AST ändern und minisprachen oder DSLs implementieren. Zigs comptime kann den AST nicht direkt ändern. Wenn Sie es wirklich möchten, können Sie ein DSL in Zig implementieren. Zum Beispiel basiert die print-Funktion von Zig auf comptime, um die Formatzeichenkette zu parsen. Basierend auf der Formatzeichenkette wird von Zigs comptime ein Graph von Funktionen zum Serialisieren Ihrer Daten konstruiert. Hier sind einige weitere Beispiele für comptime-DSLs: die TigerBeetle-Kontotests-DSL, comath: comptime-Mathematik, und zilliam, eine Bibliothek für Geometrische Algebra. Zeichenkettenvergleich mit comptime: Wie vergleichen Sie zwei Zeichenketten? Hier ist ein Ansatz, der in jeder Sprache funktioniert: javascript function stringsAreEqual(a, b) { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; return true; } Wir wissen, dass zwei Zeichenketten nicht gleich sind, wenn ihre Längen nicht übereinstimmen. Wir wissen auch, dass, wenn ein Byte nicht übereinstimmt, die Zeichenketten insgesamt nicht gleich sind. Ziemlich einfach, oder? Ja, und der generierte Assembly-Code spiegelt dies wider. Es gibt jedoch ein kleines Problem. Wir müssen einzelne Bytes aus beiden Zeichenketten laden und sie einzeln vergleichen. Es wäre schön, wenn es eine Möglichkeit gäbe, dies zu optimieren. Wir könnten SIMD verwenden, um die Eingabnzeichenketten in Blöcken zu vergleichen, aber wir würden immer noch aus zwei separaten Zeichenketten laden. In den meisten Fällen kennen wir bereits eine der Zeichenketten zur Compile-Time. Können wir es besser machen? Ja: zig fn staticEql(comptime a: []const u8, b: []const u8) bool { if (a.len != b.len) return false; for (0..a.len) |idx| { if (a[idx] != b[idx]) return false; } return true; } Der Unterschied hier ist, dass eine der Zeichenketten zur Compile-Time bekannt sein muss. Der Compiler kann diese neue Information nutzen, um verbesserten Assembly-Code zu erzeugen: assembly isHello: cmp rsi, 7 jne .LBB0_8 cmp byte ptr [rdi], 72 jne .LBB0_8 cmp byte ptr [rdi + 1], 101 jne .LBB0_8 cmp byte ptr [rdi + 2], 108 jne .LBB0_8 cmp byte ptr [rdi + 3], 108 jne .LBB0_8 cmp byte ptr [rdi + 4], 111 jne .LBB0_8 cmp byte ptr [rdi + 5], 33 jne .LBB0_8 cmp byte ptr [rdi + 6], 10 sete al ret .LBB0_8: xor eax, eax ret Ist das nicht erstaunlich? Wir haben comptime verwendet, um eine Funktion zu erstellen, die eine Zeichenkette mit "Hello!\n" vergleicht, und der Assembly-Code läuft viel schneller als der naive Vergleichsfunktion. Leider ist es immer noch nicht perfekt. Da wir die Länge der erwarteten Zeichenkette zur Compile-Time kennen, können wir viel größere Textabschnitte auf einmal vergleichen, anstatt nur Byte für Byte: ```zig const std = @import("std"); fn staticEql(comptime a: []const u8, b: []const u8) bool { const block_len = std.simd.suggestVectorLength(u8) orelse @sizeOf(usize); // Frühes Abbrechen, wenn die Zeichenkettenlängen nicht übereinstimmen if (a.len != b.len) return false; // Herausfinden, wie viele große "Blöcke" wir auf einmal vergleichen können const block_count = a.len / block_len; // Herausfinden, wie viele zusätzliche Bytes verglichen werden müssen const rem_count = a.len % block_len; // Vergleichen von "block_len" Bytes auf einmal for (0..block_count) |idx| { const Chunk = std.meta.Int(.unsigned, block_len * 8); const a_chunk: Chunk = @bitCast(a[idx * block_len ..][0..block_len].*); const b_chunk: Chunk = @bitCast(b[idx * block_len ..][0..block_len].*); if (a_chunk != b_chunk) return false; } // Vergleichen des Rests der Bytes in beiden Zeichenketten const Rem = std.meta.Int(.unsigned, rem_count * 8); const a_rem: Rem = @bitCast(a[block_count * block_len ..][0..rem_count].*); const b_rem: Rem = @bitCast(b[block_count * block_len ..][0..rem_count].*); return a_rem == b_rem; } ``` Ok, es ist ein bisschen komplexer als das erste Beispiel. Ist es es aber wert? Ja, definitiv. Der generierte Assembly-Code ist viel optimaler. Das Vergleichen größerer Blöcke nutzt größere Register und reduziert die Anzahl der bedingten Sprünge in unserem Code: assembly isHelloWorld: cmp rsi, 14 ; Die Länge von "Hello, World!\n" jne .LBB0_1 movzx ecx, word ptr [rdi + 12] mov eax, dword ptr [rdi + 8] movabs rdx, 11138535027311 shl rcx, 32 ; Vergleichen Sie keine ungültigen Daten or rcx, rax movabs rax, 6278066737626506568 xor rax, qword ptr [rdi] xor rdx, rcx or rdx, rax ; Beide Blöcke müssen übereinstimmen sete al ret .LBB0_1: xor eax, eax ret Wenn Sie versuchen, viel größere Zeichenketten zu vergleichen, werden Sie bemerken, dass diese fortgeschrittene Funktion Assembly-Code erzeugt, der die größeren SIMD-Register nutzt. Nur durch das Testen gegen "Hello, World!\n" können wir erkennen, dass wir die Laufzeitleistung dieser Funktion erheblich verbessert haben. Fazit: Ist comptime nützlich? Ich würde sagen, ja. Ich verwende es jedes Mal, wenn ich Zig-Code schreibe. Es passt sehr gut in die Sprache und eliminiert die Notwendigkeit für Vorlagen, Makros, Generics und manuelle Codegenerierung. Ja, Sie können all dies mit anderen Sprachen tun, aber es ist nicht annähernd so sauber. Jedes Mal, wenn ich Zig verwende, fühle ich, dass es einfacher ist, performanten Code für wirklich nützliche Szenarien zu schreiben. In anderen Worten, Zig ist nicht der "Turing-Sumpf". Die Möglichkeiten sind nur von Ihrer Fantasie begrenzt. Wenn Sie gezwungen sind, eine Sprache ohne gute Generics, Codegenerierung, Vorlagen, Makros oder comptime am Arbeitsplatz zu verwenden, tut es mir leid für Sie. Hoffentlich haben Sie diesen Artikel genossen. Wenn ja, überlegen Sie bitte, mich zu unterstützen. Abschließend denke ich, dass es Zeit ist, die Sprachkriege zu beenden. Turing-Vollständigkeit reicht aus, und die Details verblassen, wenn wir auf das große Bild blicken. Das bedeutet nicht, dass wir keine Lieblingssprachen haben dürfen. Menschen behaupten immer noch fälschlicherweise, "C sei schneller als Python", obwohl sie nicht die Sprache selbst testen. Auf jeden Fall genießen Sie dieses Zig-Propaganda.

Related Links