2026-06-08

[EO] Programado de la ET-SoC-1

NOTICE: This post was translated into Esperanto by an LLM, as a joke. The chip is made by Esperanto Technologies, so... it had to be done. The English original is linked below. Don't trust the translation for anything serious.

ATENTIGO: Ĉi tiu afiŝo estis tradukita en Esperanton de LLM, kiel ŝerco. La ĉipo estas farita de Esperanto Technologies, do... tio devis okazi. La angla originalo estas ligita sube. Ne fidu la tradukon por io ajn serioza.

Mi forlasis Tenstorrent iam frue en januaro 2026 kaj translokiĝis al AINekko, kiu interese aĉetis la intelektan proprietaĵon de Esperanto AI kaj malfermfontigis la stakon (kaj la RTL!). Estas amuze labori kun ĉi tiu ĉipo. Ne plu stranga VLIW maskovestita kiel RISC-V (vidu mian antaŭan afiŝon pri la vera programa modelo de Tenstorrent, kie oni devas regi 3 fadenojn samtempe, kio praktike igas ĉiun fadenon ruliĝi en sinkrona paŝado plus sinkroniga superkosto). La ET-SoC-1 estas malsama, kun siaj propraj strangaĵoj kaj trajtoj. Ni trarigardu ilin, komparante kun la Wormhole-procesoro de Tenstorrent, ĉar tiu vere estas la sola publike alirebla komparpunkto.

La ET-SoC-1

Kiel la Tenstorrent Wormhole, la ET-SoC-1 baziĝas sur RISC-V, uzas proprajn etendaĵojn, uzas NoC-on, kaj estas aranĝita en krado. Sur la ET-SoC-1, ĉiu nodo de la krado nomiĝas "shire" (graflando). Estas malsamaj tipoj de graflandoj: komputaj, memoraj, PCIe, ktp. Por simpleco, "graflando" sen specifita tipo ĉiam signifu komputan graflandon.

La NoC-nivela vido de la ET-SoC-1. Shire = Komputa Graflando, DRAM = Memora Graflando, PCIe = PCIe-Graflando, IO = IO-Graflando
Image: La NoC-nivela vido de la ET-SoC-1. Shire = Komputa Graflando, DRAM = Memora Graflando, PCIe = PCIe-Graflando, IO = IO-Graflando

Iel GPU-maniere (nu, ĉi tio estis origine dezajnita kiel GPU, vidu la historion de Esperanto Technologies interrete), la ET-SoC-1 estas konstruita kun hierarkio de kernoj. Graflandoj enhavas 4 najbarejojn. Ĉiu najbarejo tiam enhavas 8 "Minion"-kernojn. Ĉiu Minion-kerno estas RV64IMF-kerno (kun propraj vektoraj etendaĵoj) kun 2 aparataj fadenoj nomataj "harts", kaj matrica akcelilo alirebla nur por la unua hart en ĉiu minion. Estas multo por digesti:

Ĉiu graflando enhavas 4 najbarejojn kaj graflando-lokan L2-kaŝmemoron
Image: Ĉiu graflando enhavas 4 najbarejojn kaj graflando-lokan L2-kaŝmemoron

Ĉiu najbarejo tiam enhavas 8 "Minion"-kernojn. Ĉiu Minion estas 2-fadena, en-orda RV64IMFC-kerno (precize RV64IMFC+Zicsr+Zifencei kun glitpunkta divido kaj kvadrata radiko kaptitaj kaj emulitaj per programaro) kun propraj Esperanto-vektoraj etendaĵoj (256-bitaj larĝaj). Jes, ĝi estas en-orda CPU kun SMT kiel la Knights Landing-CPUoj. Male al la plej multaj komercaj SMT-realigoj, tiu en la Minion funkcias pli simile al tio, kion AMD faris en la Bulldozer-epoko: 2 fizikaj registraroj, kiuj dividas unu solan dukton. Aldone venas matrica motoro kun ioma kapablo de matrica adresado. Sed nur hart 0 havas aliron al tiu motoro. Kiam oni uzas la matrican motoron, hart 1 praktike estas senokupa aŭ agas kiel kunprocesoro.

Blokdiagramo de la Minion-kerno
Image: Blokdiagramo de la Minion-kerno

Problema fakto: la kaŝmemoroj sur la ET-SoC-1 ne estas koheraj, kaj ĉu hart 0 kaj 1 dividas la logikan L1-sekcion estas agordebla. Provoj paraleligi trans kernoj devas atenti la limojn de kaŝmemorlinioj. Alie la fadenoj havos parte ĝisdatigitajn kaŝmemorliniojn kaj reciproke surskribos la freŝe komputitajn datumojn unu de la alia. Ni poste vidos tion en praktiko.

La propra (mi ne nomos ilin proprietaj, Nekko malfermfontigis la RTL kaj sekve la opkodojn) vektora etendaĵo estas laŭ mia opinio kaj beno kaj malbeno. Esperanto dezajnis la ĉipon antaŭ RVV 1.0 (aŭ eĉ antaŭ RVV 0.7). Ili ne havis elekton krom fari sian propran vektoran etendaĵon, kion ili faris vere bone. La vektora ISA estas tre eleganta, kohera, kaj portas malmulte da arkitektura balasto por GPU-inspirita dezajno. Tamen mi ankaŭ aŭdis sufiĉe da kritiko, ke ĝi simple devus uzi RVV-on en 2026. Tiam almenaŭ la aŭtomata vektorigo de la kompililoj povus generi kodon por la procesoro. Tio estas prava kritiko, sed krom tio, ke la ĉipo estas pli malnova ol la normo mem, mi ne estas tute certa, ĉu RVV estas la ĝusta elekto por dediĉita akcelilo. Ekzistas la argumento, ke kodo simple devus ruliĝi. Sed variablo-longaj vektoroj ankaŭ havas enecajn superkostojn, kaj oni bezonas apartan agordadon por ĉiu ĉipo, kio nuligas la celon ruligi universalan antaŭkompilitan kodon... Sed mi tro anticipas.

Komparo kun la Tenstorrent Wormhole

Tenstorrent estas la sola alia kompanio publike vendanta RISC-V krado-de-kernoj-procesoron, do estas nature kompari la ET-SoC-1 kun ĝi. Bonvolu konsulti mian pli malnovan afiŝon pri la Tenstorrent Wormhole-procesoro, aŭ la oficialan Metalium-gvidilan dokumentaron (ili estas la samaj se Tenstorrent ne anstataŭigis tion, kion mi skribis) -- se vi ne konas la arkitekturon de Tenstorrent.

Kvankam la ET-SoC-1 ankaŭ estas sistolik-simila procesoro, programi ĝin estas fundamente alia afero. Ne estas deviga eksplicita DMA, ne estas apartigo inter datummovado kaj komputa kerno, ne estas magia sinkronigo inter internaj fadenoj, ekzistas veraj L1- (kaj L2-) kaŝmemoroj, kaj la listo plu longas. La ET-SoC-1 programiĝas pli kiel Xeon Phi kun nekoheraj kaŝmemoroj.

La Esperanto-SIMD-etendaĵoj

Esperanto dezajnis sufiĉe belan SIMD-etendaĵon, kiu memorigas pri la SSE-instrukcioj, uzante etenditan f0~f31-registraron por vektora konservado kaj maskan registron (estas pluraj sed nur m0 havas efikon, la aliaj estas por provizora konservado), kiu efektive maskas kromefikojn de vektoraj operacioj, inkluzive de alie nevalidaj memoraliraj artefaktoj.

La 0-a leno de la vektora registro ankaŭ estas la skalara registro
Image: La 0-a leno de la vektora registro ankaŭ estas la skalara registro

La vektora asembleo estas multe pli facile legebla ol tiu de AVX kaj NEON. Pli proksima al RVV. Konsideru la jenan ekzemplan kodon, kiu realigas la ReLU-funkcion:

fbc.ps  f10, %[z]          // bc = broadcast (dissendi) la valoron z (konstanta nulo pasita al GAS)
flw.ps  f11, %[x]          // ŝargi vektoran vorton el memoro
fmax.ps f12, f10, f11      // max(0, x)
fsw.ps  f12, %[result]     // konservi vektoran vorton al memoro

Ĉiuj vektoraj instrukcioj havas prefikson f (ne klaras al mi, kial ili ne elektis v anstataŭe; eble ili komence ne antaŭsupozis la RISC-V-etendaĵon f kaj imagis, ke ĉiuj operacioj estos vektoraj), sekvatan de la operacinomo kaj sufikso. ps indikas pakitan unuopan precizecon (packed single, t.e. po-lena unuopa precizeco). Same ekzistas pi por pakita entjero. Kompreneble ekzistas ankaŭ sortimento de horizontalaj operacioj; alie redukto fariĝus turmento, kaj granda aro de neŭralretaj operacioj estus tre teda kaj malrapida por realigi skalare. La jena estas la norma ŝablono por redukti unu vektoran registron al unu skalara valoro uzante horizontalajn operaciojn kaj misuzante la fakton ke la 0-a elemento de vektora registro estas la skalara valoro:

// Popara sumo ene de ĉiu 128-bita duono
fswizz.ps f1, f10, 0xB1         // Interŝanĝas: e0<->e1 kaj e2<->e3
fadd.ps   f2, f10, f1

// Kompletigi la sumon por ĉiu 128-bita duono
fswizz.ps f3, f2, 0x4E          // Interŝanĝas: e0,e1 <-> e2,e3
fadd.ps   f4, f2, f3

// Sumi trans la du 128b-duonoj
fmvz.x.ps t0, f4, 4
fbcx.ps   f5, t0


// ie en C
// float vout;
fadd.ps   %[vout], f4, f5      // vout estas skalaro, sed ĉar la 0-a
                               // elemento estas la skalara valoro, ni povas
                               // uzi ĝin rekte

Bonvolu konsulti la PRM por pliaj detaloj pri la vektora instrukciaro.

Saluton mondo

La gastiga API de la platformo aspektas tre simile al OpenCL - tiom, ke mi havas bonajn kialojn kredi, ke oni dezajnis ĝin por imiti OpenCL. La pravaloriga logiko estas praktike la sama, kun unu grava escepto - subteno por emulita ĉipo. Oni ne bezonas fizikan ĉipon (kvankam la emulado estas tre malrapida) por verki programojn por la platformo.

// Emulado
#ifdef EMULATION
sysEmuOptions.executablePath = fs::path(SYSEMU_INSTALL_DIR) / "sys_emu";
sysEmuOptions.runDir = current_path;
sysEmuOptions.maxCycles = std::numeric_limits<uint64_t>::max();
sysEmuOptions.minionShiresMask = 0x1FFFFFFFFu;
sysEmuOptions.puUart0Path = current_path / "pu_uart0_tx.log";
sysEmuOptions.puUart1Path = current_path / "pu_uart1_tx.log";
sysEmuOptions.spUart0Path = current_path / "spio_uart0_tx.log";
sysEmuOptions.spUart1Path = current_path / "spio_uart1_tx.log";
sysEmuOptions.startGdb = false;

// Ruli sur simulita aparato!
std::shared_ptr<dev::IDeviceLayer> deviceLayer =
    dev::IDeviceLayer::createSysEmuDeviceLayer(sysEmuOptions, 1);
#else
// Ruli sur vera aparato
std::shared_ptr<dev::IDeviceLayer> deviceLayer =
    dev::IDeviceLayer::createPcieDeviceLayer();
#endif


auto runtime = rt::IRuntime::create(deviceLayer);
auto devices = runtime->getDevices();
if (devices.empty()) {
    std::cerr << "No devices found.\n";
    return 1;
}
auto device = devices[0];
auto stream = runtime->createStream(device);

Post la agordo, ĝi funkcias proksimume kiel OpenCL. La API ne subtenas dumrulan kompiladon; oni devas kompili la kernojn anticipe.

auto elfData = readFile(kernelPath);
if (elfData.empty()) {
    std::cerr << "Failed to read kernel ELF.\n";
    return 1;
}
auto loadResult = runtime->loadCode(stream, elfData.data(), elfData.size());
runtime->waitForEvent(loadResult.event_);
auto kernel = loadResult.kernel_;

Malkiel OpenCL, kiu abstraktas la laborŝarĝon en laborgrupojn kaj labordimensiojn, la ET-platformo simple demandas, kiujn graflandojn vi volas aktivaj dum la kerna rulo, per unu sola bitmasko. Kaj tiam ĉiuj najbarejoj (kaj sekve ĉiuj minionoj) ene de la aktiva graflando aktivas.

rt::KernelLaunchOptions launchOpts;
launchOpts.setShireMask(0x1); // <- La ET-SoC-1 enhavas 32 komputajn graflandojn. Ĉi tiu masko regas kiuj graflandoj estas aktivaj.

// Ebligi sencimigan presadon por `et_printf`. La API permesas regi, kiu graflando kaj kiu miniono en ĝi
// rajtas presi. Nuntempe ĉi tio diras "mi volas ebligi presadon por ĉiuj fadenoj en graflando 0"
launchOpts.setUserTracing(
    reinterpret_cast<uint64_t>(traceDevBuf),
    static_cast<uint32_t>(kTraceBufferSize),
    0,                              // threshold
    0x1,                            // trace shireMask
    0xFFFFFFFFFFFFFFFFULL,          // threadMask - ĉiuj fadenoj
    0xFFFFFFFFU,                    // eventMask - ĉiuj eventoj
    0xFFFFFFFFU                     // filterMask - ĉiuj niveloj
);

// Lanĉi la kernon
// Malkiel OpenCL, denove, parametroj estas binara datumbloko sur ET (kvankam ni ne uzas ĝin nun)
std::vector<std::byte> kernelArgs(64);
auto launchEvent = runtime->kernelLaunch(
    stream, kernel, kernelArgs.data(), kernelArgs.size(), launchOpts);
runtime->waitForStream(stream);

Kaj la kerno kiu ruliĝos sur la aparato:

int64_t entry_point(void)
{
    et_printf("Hello World from hart %d\n", get_hart_id());
    return 0;
}

Poste, en la gastiga kodo, ni prenas tion el la presbufro kaj elprintas ĝin:

auto* traceHeader = reinterpret_cast<const trace_buffer_std_header_t*>(hostTraceBuf.data());
const trace_entry_header_t* entry = nullptr;
int count = 0;

while ((entry = Trace_Decode(traceHeader, entry))) {
    if (entry->type != TRACE_TYPE_STRING) {
        continue;
    }
    auto* strEntry = reinterpret_cast<const trace_string_t*>(entry);
    std::cout << "[hart " << entry->hart_id << "] " << strEntry->string << "\n";
    ++count;
}

Kaj tio eligas:

[hart 0] Hello World from hart 0
[hart 1] Hello World from hart 1
[hart 2] Hello World from hart 2
[hart 3] Hello World from hart 3
[hart 4] Hello World from hart 4
[hart 5] Hello World from hart 5
...

Vi povas trovi la fontkodon ĉi tie.

Vektora adicio

Presado estas agrabla, sed kiel oni faras matematikon? Rememoru el la enkonduko, ke la kaŝmemoro ne estas kohera, do oni devas atenti pri la dispartigo kaj videbleco de la skriboj. Por vektora adicio tio estas bagatela - certigu, ĉu ĉe la gastiganto ĉu ĉe la aparato, ke ĉiu hart prilaboru datumojn laŭ 64-bajtaj limoj. La plej simpla kerno aspektas jene:

#define CACHELINE_SIZE 64
int64_t entry_point(KernelParameters* params, void* env)
{
    int64_t threadId = get_hart_id();
    int64_t workChunkSize = params->size / params->numThreads;
    int64_t baseIdx = workChunkSize * threadId;

    if(workChunkSize % (CACHELINE_SIZE / sizeof(int)) != 0) {
        return -1; // indiki al la gastiganto ke la plenumo malsukcesis
    }

    int* a = params->a;
    int* b = params->b;
    int* c = params->c;

    for(int64_t i=0; i<workChunkSize;i++) {
        c[i+baseIdx] = a[i+baseIdx] + b[i+baseIdx];
    }
    return 0;
}

Skalara kodo estas malrapida. Do ni ankaŭ rigardu vektorigitan version. La samaj kaŝmemor-koherecaj postuloj validas, sed krome oni devas mane vektorigi la iteracion kaj alvoki SIMD-instrukciojn por plenumi la adicion. Notu la uzon de flq2 anstataŭ flw.ps ĉi tie. Ĉi-kaze tio ne gravas, sed flq2 ne estas influata de la maskmekanismo de m0, dum flw.ps jes.

int64_t entry_point(KernelParameters* params, void* env)
{
    int64_t threadId = get_hart_id();
    int64_t workChunkSize = params->size / params->numThreads;
    int64_t baseIdx = workChunkSize * threadId;
    int* a = params->a;
    int* b = params->b;
    int* c = params->c;

    if(workChunkSize % (CACHELINE_SIZE / sizeof(int)) != 0) {
        return -1; // indiki al la gastiganto ke la plenumo malsukcesis
    }

    // Agordi la maskan registron m0 al 0xFF (ĉiuj 8 lenoj aktivaj)
    // MOV.M.X movas X-registron OR-itan kun 8-bita tujvaloro en M-registron.
    asm volatile ("mov.m.x m0, zero, 0xFF");

    // La vektorigita versio de la iteracio. Funkcias nur se la problemgrandeco estas oblo
    // de 8. Tamen, pro kaŝmemora kohereco, la oblo estas 16, do ĉio glatas
    for (int64_t i=baseIdx; i <= baseIdx+workChunkSize - 8; i += 8) {
        asm volatile (
            "flq2    f0, 0(%0)\n\t"     // Nemaskita 256-bita ŝargo el tabelo a
            "flq2    f1, 0(%1)\n\t"     // Nemaskita 256-bita ŝargo el tabelo b
            "fadd.pi f2, f0, f1\n\t"    // Pakita Entjera Adicio (sub la rego de m0)
            "fsq2    f2, 0(%2)\n\t"     // Nemaskita 256-bita konservo al tabelo c
            : // Neniuj eligaj operandoj
            : "r"(a + i), "r"(b + i), "r"(c + i)
            : "f0", "f1", "f2", "memory"
        );
    }
    return 0;
}

Vi povas trovi la fontkodon ĉe la jena ligilo:

Interesaj aparataj trajtoj

Tio estas la baza enkonduko al la aparataro. Nun, kion alian la aparataro kapablas? Krom tio, ke ĝi estas 1024-fadena multkerna RISC-V-procesoro kun nekoheraj kaŝmemoroj kaj nenorma vektora etendaĵo? La aparataro mem subtenas sufiĉe da lertaĵoj kaj ebligas aliajn pli... dubindajn. Ne ke la rekte subtenataj trajtoj ne estus interesaj.

(Virtuala) L3-kaŝmemoro

Mi ankoraŭ memoras la ŝokon, kiun mi havis kiam IBM anoncis la Telum-ĉipon por sia z16-mainframe. Kion vi celas per tio, ke oni povas uzi la L3-on de alia kerno kiel ĉip-skalan komunan L3-on? Certe oni povas tion fari... teorie... sed ĉu tio estas bona ideo? Montriĝas, ke la ET-SoC-1 subtenas ion similan. Ĉiu graflando venas kun 4MB da komunaj L2-bankoj, kaj, defaŭlte, 2.5MB el tio estas dispartigita kiel L2SCP (kiun ni diskutos poste), 0.5MB kiel la graflando-loka L2-kaŝmemoro, kaj 1MB kiel la ĉip-skala komuna L3.

Konsentite, la ET-SoC-1 havas statikan L3 anstataŭ la freneza dinamika viktima kaŝmemoro de IBM. Ĉiuokaze, tiu L3 estas tre utila kaj agas kiel skuabsorbilo antaŭ ol aferoj iras al DRAM, kio estas tre bela dezajna elekto de Esperanto. Sed ĝi ankaŭ iom kaŭzas kapdoloron. Ofte, kiam la PRM diras "preteriras la kaŝmemoran hierarkion", tio signifas, ke ĝi preteriras la L1 kaj L2, sed la L3 certe ankoraŭ kuŝas sur la vojo. Notu ankaŭ, ke la kombino de L3 kaj nekoheraj kaŝmemoroj povas fuŝi aferojn sufiĉe malbone. Aparte, se vi intencas fari ion ajn, kio iras rekte al DRAM, vi devas certiĝi, ke ne ekzistas kopio de la sama kaŝmemorlinio en la L3.

Klasikaj nekohera-kaŝmemoraj problemoj.

La matrica motoro

La matrica motoro fakte konsistas el 2 partoj. Unu estas la efektiva parto, kiu faras la matrican multiplikon; la alia movas viajn datumojn en la unuan. La PRM nomas ĉi-lastan la tensorŝarga komando. Sed mi kutime nomas ĝin la 2D-adresado kaj kahelŝargo.

Unu el la problemoj de Tenstorrent, malgraŭ kelkaj vere grandaj progresoj, pri kiuj mi aŭdis de {fontoj}, estas, ke ofte, por ke ĉi tiaj ASIC-oj funkciu bone, la datumoj devas esti ĝuste aranĝitaj en la memoro. Tenstorrent havas sian 32x32-kradon de valoroj en memoro kaj postulas, ke preskaŭ ĉiuj datumoj estu tiel aranĝitaj antaŭ uzo. Tio estas problemo. La plej multaj inferencaj kadroj — GGML, ONNXRuntime, TinyGrad, kiun ajn vi elektas — supozas, ke vi uzas GPU-on, donas al vi vico-ĉefajn datumojn, kaj atendas la rendimentajn ecojn, kiuj sekvas el tiu fakto: vidoj estas senkostaj, formoŝanĝo povas esti senkosta, ktp. Tio preskaŭ neniam veras, kiam oni kaheligas la aranĝon.

La ET-SoC-1 provizas elegantan solvon por tio. Anstataŭ fleksi la programaran kontrakton aŭ aldoni amasan aparataron kiu absorbas la fluitajn datumojn por reuzo, simple igu la DMA-on respekti la 2D-kahelojn. Kiam oni ŝargas kahelon, anstataŭ "jen la montrilo, ŝargu 2KiB da datumoj", oni diras al la aparataro la montrilon, la paŝon inter vicoj, kaj kiom da vicoj ŝargi (ĉar la kahellarĝo estas kutime fiksita, pro komputefikecaj kialoj). Tiel oni povas agordi la paŝon laŭ la matriclarĝo kaj la nombron de ŝargotaj vicoj laŭ la kahelalto, lanĉi la DMA-on, kaj bum — via kahelo estas ŝargita.

Kiel la matrica motoro ŝargas kahelojn
Image: Kiel la matrica motoro ŝargas kahelojn

Matrica ŝargo subtenas transformojn samtempe kun la ŝargo: interplektado (interŝanĝo de neparaj kaj paraj elementoj), transpono, aŭ nenio. Estas ankaŭ TensorQuant-subsistemo kiu donas al vi duon-programeblan matrican malkvantigon de int8 al FP32. Sed tio funkcias pli kiel tradicia CNN-kvantigo anstataŭ la pli lastatempaj blokaj glitpunktaj aŭ OCP MXFP*-formatoj.

La matrica motoro mem estas deca. Ĝusta FP32-subteno kun stranga sed uzebla FP16-subteno. La amuza kaj interesa demando estas, kie la matrica komputado konservas ĉion. Pro kialoj, kiujn mi povas nur diveni (verŝajne spacefikeco), ne vere ekzistas matrica registro por teni viajn enigajn/eligajn matricojn. Anstataŭe, parto de via L1-kaŝmemoro estas prenita por teni la enigajn matricojn kaj ĝi konservas ilin en viaj regulaj f0~f31-vektoraj registroj.

Pri ĉi tio miaj sentoj estas duflankaj. Unue la malavantaĝoj:

  • Reduktita L1-kaŝmemora kapacito
  • Vi ne povas porti glitpunktan staton trans matrican matematikon, sen kompilila subteno
  • Vi aŭ surskribas la tutan FP-registraron (malrapide) AŬ rompas la C-abstraktan maŝinon (rompiĝas en strangaj manieroj)
  • Konservi kaj rekomenci partajn rezultojn estas malrapide (32 vektoraj konservoj + 32 vektoraj ŝargoj)
  • Kaheligo (preter aparataj kaheloj) estas malfacile atingebla ĉar eligo kaj enigo ne loĝas en la sama adresspaco

Kelkaj avantaĝoj:

  • Oni povas facile manipuli la rezulton de la matrica multipliko... simple uzu vektorajn operaciojn
  • Nur memoru ke ĉiu registro estas uzata, do vi bezonas la stakspacon por liberigi kelkajn por provizoraj valoroj
  • Kaj vi devas kodi ĉiujn FP-operaciojn, eĉ skalarajn, en asembleo ĉar vi devas scii precize kiuj registroj estas uzataj kaj konservi + restarigi ilin

L2SCP

La skrapkajera areo estas grandega defaŭlte: 2MB por graflando. L2SCP agas pli-malpli kiel la loka memoro de GPU, kiu estas dividata de grupo de kernoj (ĉiuj harts en graflando). Kaj kun nedeviga tutĉipa reĝimo, kiu mapas la lokan L2SCP de ĉiu graflando al la sama adreso trans ĉiuj graflandoj -- tio estas simple fike bona ideo.

Hart 0 kaj 1

Kiel dirite supre, la Minion-kerno pakas 2 fadenojn en unu solan fizikan kernon. La 2 fadenoj nomiĝas harts: hart 0 kaj hart 1. La Minion estas unuopa-eliga, en-orda kerno kun 2 fizikaj registraroj (po unu por ĉiu hart). La harts estas plejparte simetriaj kun la escepto ke nur hart 0 havas aliron al la matrica motoro. Tial la plej multaj kernoj, kiujn vi skribos, uzos kaj hart 0 kaj hart 1 por atingi maksimuman rendimenton. Nur kiam la matrica motoro estas bezonata, vi plejparte uzos ekskluzive hart 0, dum hart 1 aŭ restas senokupa aŭ dumfluge repakas matricajn datumojn (ekzemple kiam la datumaranĝo ne estas tia, kian la matrica motoro subtenas). Evidente, lasi hart 1 senokupa donas pli da eligaj ŝancoj al hart 0. Tio foje postulas delikatan ekvilibradon.

Konfuze, tutmondaj aŭ najbarejo-lokaj hart-identigiloj ankaŭ estas uzataj dum kerna programado. Hart 0 povas signifi la 0-an hart en la najbarejo, aŭ la 0-an hart de la tuta ĉipo... oni kutime povas eltrovi tion el la kunteksto. Sed mi avertu, kie averto indas.

La FlashAttention-kerno

Rapida FlashAttention sur la ET-SoC-1 estas malfacile atingebla, sed ĝi perfekte montras, kiel la ĉipo povas esti uzata kaj kiel elegante Esperanto dezajnis la aferon por trakti neantaŭviditajn bezonojn. Paciencu malgraŭ la komplekseco, kaj vi vidos, kiom multe la ĉipo kapablas. La ŝlosilvorto estas "kapablas"; kiel ĉe optimumigoj ĝenerale, fari neniun laboron pli bonas ol rapide fari iom da laboro. Sed GGML simple petas ion, kion la aparataro ne povas fari rekte. Praktike, en LLM-inferenco GGML volas FP16-operaciojn, kie FLASH_ATTN_EXT akceptas kaj produktas la jenajn enigajn kaj eligajn formojn:

Tensorne0 (contig)ne1ne2ne3Comment
Qn_embd_kn_batchn_headne3
Kn_embd_kn_kvn_head_kvne3
Vn_embd_vn_kvn_head_kvne3NE transponita, malkiel ĉe Nvidia
maskn_kvn_batchne32ne33
resultn_embd_vn_headn_batchne3Permutita kontraŭ Q

Por resumi, kiel FlashAttention funkcias — kaj ĉar la algoritmo estas tiel komplika, ke eĉ mi apenaŭ povas teni ĝin en la kapo — la jena pseŭdokodo montras tre altnivelan superrigardon de la algoritmo.

for (int h  = 0; h  < n_head;  h++) {                 // ĉiu kapo estas sendependa
  for (int q0 = 0; q0 < n_batch; q0 += BR) {          // kahelo de BR demandovicoj

    tensor_load(Q_tile, Q[:, q0:q0+BR, h]);           // ŝargi unufoje, reuzi sube

    m = -inf;                                         // kuranta vic-maksimumo [BR]
    l = 0;                                            // kuranta vic-sumo      [BR]
    O = 0;                                            // kuranta eligo         [BR, D_v]

    // flui KV en kaheloj
    for (int k0 = 0; k0 < n_kv; k0 += BC) {

      tensor_load(K_tile, K[:, k0:k0+BC, h]);
      tensor_load(V_tile, V[:, k0:k0+BC, h]);

      // 1-a redukto: S = Q · K^T
      tensor_fma(S, Q_tile, K_tile);                  // S[BR, BC]
      S *= scale;

      // online-softmax-ĝisdatigo (po demandovico)
      m_new = max(m, rowmax(S));
      alpha = exp(m - m_new);                         // reskali malnovan staton
      P     = exp(S - m_new);                         // probabloj por ĉi tiu kahelo
      l     = alpha * l + rowsum(P);

      // 2-a redukto: O += P · V
      O = alpha * O;
      tensor_fma(O, P, V_tile);                       // O[BR, D_v] += P · V

      m = m_new;
    }

    // finpretigi kaj skribi
    O = O / l;
    tensor_store(result[:, h, q0:q0+BR], O);          // permutita aranĝo
  }
}

Ĝi aspektas facila koncepte. Reale, ĝi estas alia problemo.

Unue, kvankam la matrica motoro subtenas FP32-, FP16-, kaj INT8-matricajn operaciojn, GGML volas FP16, ĉar tion uzas la Tensor Cores de Nvidia. La FP16-matrica multipliko en la ET-SoC-1, pro aparata strangaĵo, postulas, ke la B-matrico estu interplektita. Rigardante la enigan formon, la Q @ K funkcias same kiel la MUL_MAT-operacio de GGML, kaj K estas antaŭ-transponita. Tamen, ne ekzistas subteno por interplekta kaj transpona ŝarga operacio en la matrica motoro.

La tabelo en la PRM montranta subtenatajn tensorŝargajn transformojn
Image: La tabelo en la PRM montranta subtenatajn tensorŝargajn transformojn

Poste, en la pseŭdokodo S estas intermeza matrico produktita de la matrica motoro, surĉipe ĝi loĝas sur la registroj f0~f31, kaj estas uzata poste por la 2-a tensor_fma. Tamen estas amaso da online-softmax-kalkuloj, kiuj ankaŭ bezonas la samajn glitpunktajn registrojn por vektoraj operacioj (krom se vi pretas uzi softfp, sed tio estas tro malrapida por ruligi LLM-on). Kaj fine, ideale ni tensorkonservus O reen al memoro. Sed la matrica motoro faras nur matrican multiplikon, sen iaj po-elementaj kapabloj.

Por ĉiuj legantoj, kiuj komprenas, kiel HPC kutime funkcias, la solvo estas samtempe evidenta kaj freneza. Ĉar hart 1 ne havas aliron al la matrica motoro kaj estus senokupa dum FlashAttention ĉiuokaze, la solvo al la bezono de transpono + interplekto estas aktivigi hart 1, krei semaforon por signalado, uzi la vektorajn operaciojn de hart 1 por ŝargi kaj interplekti la submatricon, skribi ĝin al L2SCP, kaj signali al hart 0, ke ĝi ŝargu kaj transponu ĝin en la matrican motoron. Tiu funkcio aspektas jene:

// interplekti 16x16-submatricon en K kaj konservi al eliga
// montrilo (sur la L2SCP) por ke hart 0 povu ŝargi kaj transponi ĝin
void pack_k_for_transpose16(et_fp16_t * out,
                       const char * k_base,
                       int64_t kv_start,
                       int64_t dk_start,
                       int64_t kv_count,
                       int64_t nb1_k)
{
    unsigned long old_mask;
    __asm__ volatile(
        "mova.x.m  %[ms]            \n\t"
        "mov.m.x   m0, x0, 0xFF     \n\t"
        : [ms] "=&r"(old_mask) ::);

    for (int j = 0; j < (int)kv_count; ++j) {
        const et_fp16_t * k_row =
            (const et_fp16_t *)(k_base + (kv_start + j) * nb1_k) + dk_start;
        et_fp16_t * even_row = out + (j * 2)     * 32;
        et_fp16_t * odd_row  = out + (j * 2 + 1) * 32;
        __asm__ volatile(
            "flw.ps    f2, 0(%[src0])  \n\t"   // ŝargi vicon[0..15]
            "flw.ps    f3, 0(%[src1])  \n\t"   // ŝargi vicon[16..31]
            "fpackreph.pi f4, f2       \n\t"   // even_lo el src0
            "fpackreph.pi f6, f3       \n\t"   // even_lo el src1 (interplektita)
            "fsrli.pi  f5, f2, 16      \n\t"   // ŝovi src0 por nepara
            "fsrli.pi  f7, f3, 16      \n\t"   // ŝovi src1 por nepara (interplektita)
            "fpackreph.pi f5, f5       \n\t"   // nepara el src0
            "fpackreph.pi f7, f7       \n\t"   // nepara el src1
            "mov.m.x   m0, x0, 0x0F   \n\t"
            "fcmovm.ps f4, f4, f6      \n\t"   // kunfandi parajn duonojn
            "fcmovm.ps f5, f5, f7      \n\t"   // kunfandi neparajn duonojn
            "mov.m.x   m0, x0, 0xFF   \n\t"
            "fsw.ps    f4, 0(%[even])  \n\t"
            "fsw.ps    f5, 0(%[odd])   \n\t"
            :
            : [src0] "r"(k_row),
              [src1] "r"(k_row + 16),
              [even] "r"(even_row),
              [odd] "r"(odd_row)
            : "f2", "f3", "f4", "f5", "f6", "f7", "memory"
        );
    }

    __asm__ volatile(
        "mova.m.x  %[ms]            \n\t"
        :: [ms] "r"(old_mask)
    );

    for (int j = (int)kv_count; j < TILE_KV; ++j) {
        et_fp16_t * even_row = out + (j * 2)     * 32;
        et_fp16_t * odd_row  = out + (j * 2 + 1) * 32;
        for (int l = 0; l < TILE_K / 2; ++l) {
            even_row[l] = 0;
            odd_row[l]  = 0;
        }
    }
}


// Por alvoki ĝin en hart 1
for (int64_t dk_chunk = 0; dk_chunk < dk; dk_chunk += TILE_K) {
    int buf = chunk_id & 1;

    // Retropremo: antaŭ ol surskribi buf[buf] ĉe peco N
    // (kio forigos pecon N-2), atendi ke hart 0 afiŝu
    // ke ĝi finis kun peco N-2. Bremsas ambaŭ
    // direktojn de la duobla bufrado.
    if (chunk_id >= 2) {
        et_sem_wait(ET_BARRIER_MINION);
    }

    // Antaŭpreni K-datumojn por ĉi tiu peco
    prefetch_kv_to_l2(k_head, kv_base, dk_chunk, kv_count, k->nb[1]);

    pack_k_for_transpose16(scp_kp[buf], k_head, kv_base, dk_chunk,
                           kv_count, k->nb[1]);

    FENCE;
    // Elflui ĉiujn skribojn al L2SCP por ke hart 0 povu vidi la pakitajn K-datumojn
    // kiam ĝi eligas la ŝargon
    flush_to_l2(scp_kp[buf], 16, 64);
    flush_to_l2((et_fp16_t *)((char *)scp_kp[buf] + 1024), 16, 64);
    WAIT_CACHEOPS;

    // Signali: ĉi tiu buf estas preta por ke hart 0 konsumu.
    et_sem_post(ET_BARRIER_MINION);

    chunk_id++;
}

Tiam hart 0 povas uzi tion kion hart 1 produktas.

... // agordo por ke ni povu dukti la ŝargon
for (int64_t i = 1; i < n_dk_chunks; i++) {
    int buf            = chunk_id & 1;
    int k_slot_prev    = (int)((i - 1) & 1);
    int k_slot         = (int)(i & 1);

    et_sem_wait(ET_BARRIER_MINION);
    tensor_load(
        false, false, K_BUFS[k_slot], TENSOR_LOAD_TRANSPOSE16, 0,
        (uint64_t)scp_kp[buf], 0, 15, 64, 1);

    tensor_fma(
        (kv_count < TILE_KV), 3, 0, 15, 0,
        false, false, false, false,
        K_BUFS[k_slot_prev], (uint64_t)(i - 1),
        TENSOR_FMA_OP_FP16, (i == 1));

    tensor_wait(TENSOR_LOAD_WAIT_1);   // K[i] en L1
    et_sem_post(ET_BARRIER_MINION);    // liberigi scp_kp[buf] FRUE
    tensor_wait(TENSOR_FMA_WAIT);      // poste atendi FMA[i-1]
    chunk_id++;
}
... // kaj iom da vosta traktado

Ĉar tensor_fma skribas al la tuta glitpunkta registraro, ni devas mane marki ilin kiel surskribitajn (clobber) por devigi la kompililon ne porti glitpunktajn statojn trans tensor_fma-alvokojn, kvankam ĝi aspektas kiel ordinara funkcio. Por dekodado ni uzas f0 kaj f1, ĉar tiuj ricevas la eligon (la unua vico, ĉar batch=1). Poste ni eltiras la labordatumojn, por ke ni povu apliki al ili la ĝisdatigon de la softmax-statistikoj.

__asm__ volatile("" ::: "f0", "f1");

// Eltiri QK^T-poentojn el la vektora registraro
unsigned long _ms;
__asm__ volatile(
    "mova.x.m  %[ms]                \n\t"
    "mov.m.x   m0, x0, 0xFF         \n\t"
    "fbc.ps    f2, 0(%[p_scale])    \n\t"
    "fmul.ps   f0, f0, f2           \n\t"
    "fmul.ps   f1, f1, f2           \n\t"
    "fsw.ps    f0, 0(%[dst])        \n\t"
    "fsw.ps    f1, 32(%[dst])       \n\t"
    "mova.m.x  %[ms]                \n\t"
    : [ms] "=&r"(_ms)
    : [dst] "r"(scores), [p_scale] "r"(&scale)
    : "f0", "f1", "f2", "memory"
);

Post kiam la krudaj QK^T-poentoj estas eltiritaj, ni devas komputi la online-softmax. Oni pensus, ke ni povus simple apliki exp() kaj sumi, sed skalara redukto estus malrapida. Anstataŭe, ni duktas la eksponentadon en vektora asembleo, interplektante kalkulojn por ambaŭ duonoj de la vico trans sendependaj registroj (kiel f2 kaj f3) dum ni spuras la kurantan maksimumon M kaj denominatoron S:

const float log2e = 1.4426950408889634f;
float S_tile;
unsigned long _ms;
__asm__ volatile(
    "mova.x.m  %[ms]              \n\t"
    "mov.m.x   m0, x0, 0xFF       \n\t"
    "flw.ps    f2, 0(%[sc])       \n\t" // Ŝargi la unuajn 8 poentojn
    "fbc.ps    f4, 0(%[pM])       \n\t" // Dissendi maksimumon M
    "flw.ps    f3, 32(%[sc])      \n\t" // Ŝargi la sekvajn 8 poentojn
    "fbc.ps    f5, 0(%[pL])       \n\t" // Dissendi log2e
    "fsub.ps   f2, f2, f4         \n\t" // poento - M
    "fsub.ps   f3, f3, f4         \n\t"
    "fmul.ps   f2, f2, f5         \n\t" // (poento - M) * log2e
    "fmul.ps   f3, f3, f5         \n\t"
    "fexp.ps   f2, f2             \n\t" // exp2 (duktita!)
    "fexp.ps   f3, f3             \n\t"
    "fsw.ps    f2, 0(%[wt])       \n\t" // Konservi pezojn
    "fsw.ps    f3, 32(%[wt])      \n\t"
    "fadd.ps   f2, f2, f3, rne    \n\t"
    "fswizz.ps f3, f2, 0xB1       \n\t"
    "fadd.ps   f2, f2, f3, rne    \n\t"
    "fswizz.ps f3, f2, 0x4E       \n\t"
    "fadd.ps   f2, f2, f3, rne    \n\t"
    "fmvz.x.ps t0, f2, 4          \n\t"
    "fbcx.ps   f3, t0             \n\t"
    "fadd.ps   %[st], f2, f3, rne \n\t" // Sumi trans 16 elementoj
    "mova.m.x  %[ms]              \n\t"
    : [ms] "=&r"(_ms), [st] "=f"(S_tile)
    : [pM] "r"(&M), [pL] "r"(&log2e),
      [sc] "r"(scores), [wt] "r"(weights)
    : "f2", "f3", "f4", "f5", "t0", "memory"
);

Post la ĝisdatigo de niaj softmax-statistikoj, ni konvertas ĉi tiujn pezojn reen al FP16 kaj multiplikas ilin kun V. Malkiel QK^T, V ne estas transponita en memoro (vico-ĉefa, grandeco n_kv x n_embd_v). Ĉar V ne estas transponita, ni povas ŝargi plenajn kahelojn rekte el DRAM uzante TENSOR_LOAD_INTERLEAVE16 por kongrui kun la FMA-postuloj de la matrica motoro. Ni duoble-bufras ĉi tiujn ŝargojn por kaŝi la DRAM-latentecon malantaŭ la matematiko. Sed atendu - kio, se ni trafas partan kahelon proksime de la fino de la sekvenco? La aparata interplekta ŝargo legos preter la tensoraj limoj kaj prenos rubon. Do ni devas skribi programaran retrofalon (pack_v_interleaved) por mane formati partajn kahelojn sur hart 0.

Ĝuste kiam vi pensas, ke ĉio funkcias, vi trafas la kapdoloron de utiligo dum dekodado. Dum LLM-generado la stapla grandeco kutime estas 1. Tio signifas, ke la totala nombro de aktivaj vicoj estas nur la nombro de atentokapoj (kutime 32). Se oni laŭvice disdonas po unu vico al ĉiu minion-kerno, nur 32 el la 1024 fadenoj sur la ĉipo ruliĝas. La ceteraj 992 fadenoj restas malvarmaj kaj senokupaj, kio faligas la utiligon ĝis doloriga 3%. Por teni la ĉipon varma, ni realigas Split-KV: ni grupigas minionojn de la sama graflando en teamojn de grandeco k_splits, kiuj kunlaboras pri unu sola vico, dividante inter si la KV-kaŝmemoran dimension. Ĉiu minion komputas partan kurantan maksimumon M_p, kurantan sumon S_p, kaj lokan akumulilan vektoron acc_p en L2SCP.

Sed ĉi tie vi trafas la nekoherajn kaŝmemorojn. Ĉar la L1D-kaŝmemoroj ne estas koheraj, se kunulaj minionoj skribas siajn partajn statistikojn kaj akumulilojn al L2SCP, kiel la teama reduktilo (minion 0) legas ilin? Se ĝi simple aliras la memoron, ĝi legos malfreŝan rubon el sia propra L1D-kaŝmemoro. Ni igas la minionojn skribi kaj elflui siajn datumojn al L2SCP uzante kaŝmemor-preterirajn aŭ eksplicitajn elfluojn, kaj poste trafi graflando-lokan plenumbarieron (et_barrier(ET_BARRIER_SHIRE)). Antaŭ ol la reduktilo povas legi la statistikojn de kunulo, ĝi devas eksplicite forpeli sian propran L1D-kopion de la adresoj de la kunulo (evict_to_l2), devigante la sekvan legon preni la freŝajn valorojn el la komuna L2SCP. Ĝi tiam reskalas sian propran akumulilon kaj aldonas la akumulilon de la kunulo uzante propran vektor-asemblean kunfandan iteracion:

static inline void __attribute__((always_inline))
merge_rescale_add_asm(float * acc,
                      const float * peer_acc,
                      int64_t dv,
                      float alpha_own,
                      float alpha_peer) {
    unsigned long old_mask;
    __asm__ volatile(
        "mova.x.m  %[ms]              \n\t"
        "mov.m.x   m0, x0, 0xFF       \n\t"
        "fbc.ps    f4, 0(%[ao])       \n\t" // Dissendi propran reskalan faktoron
        "fbc.ps    f5, 0(%[ap])       \n\t" // Dissendi kunulan reskalan faktoron
        : [ms] "=&r"(old_mask)
        : [ao] "r"(&alpha_own), [ap] "r"(&alpha_peer)
        : "f4", "f5"
    );

    for (int64_t d = 0; d < dv; d += 8) {
        __asm__ volatile(
            "flw.ps    f2, 0(%[a])      \n\t" // Ŝargi propran akumulilon
            "flw.ps    f3, 0(%[p])      \n\t" // Ŝargi kunulan akumulilon
            "fmul.ps   f2, f2, f4       \n\t" // Propra *= alpha_own
            "fmul.ps   f3, f3, f5       \n\t" // Kunula *= alpha_peer
            "fadd.ps   f2, f2, f3       \n\t" // Adicii
            "fsw.ps    f2, 0(%[a])      \n\t" // Konservi reen al L2SCP
            :
            : [a] "r"(acc + d), [p] "r"(peer_acc + d)
            : "f2", "f3", "memory"
        );
    }
    __asm__ volatile("mova.m.x %0" :: "r"(old_mask));
}

Dua graflanda bariero certigas, ke la aliaj minionoj ne surskribu siajn L2SCP-laborspacojn, dum la reduktilo ankoraŭ legas. Fine la reduktilo normaligas la akumulilon, multiplikante per la inversigita fina sumo (1/S), kaj skribas la pretan vicon reen al DRAM. La altnivela fluo aspektas jene:

Altnivela fludiagramo de FlashAttention sur la ET-SoC-1
Image: Altnivela fludiagramo de FlashAttention sur la ET-SoC-1

Kaj tio estas nur duono de la rakonto pri funkciigado de FlashAttention tiel rapide kiel la ĉipo kapablas. Ĝi estas grava defio, sed espereble neniu defio estas tro malfacila por la ĉipo. Povas esti dolore realigi algoritmon, kiun la dezajnistoj neniam antaŭvidis. Sed tio ĉiam eblas, kaj la rezultoj ofte estas sufiĉe efikaj. Eble mi devus verki apartan afiŝon tute dediĉitan al la realigo de FlashAttention sur la ET-SoC-1.


Ĉiuokaze, espereble ĉi tiu afiŝo estas interesa legaĵo por homoj, kiuj volas vidi, kiel la ĉipo de Esperanto funkcias, aŭ kiuj simple malkontentas pri la regado de GPU-oj kaj dezirus, ke ni povu fari pli bone. Bonvenon ĉe AIFoundry - la malfermfonta komunumo de Nekko - kaj aliĝu al nia misio por malfermfonta AI.