diff --git a/docs/gpu-code-objects.md b/docs/gpu-code-objects.md index 0171b1b0c4..81af07327e 100644 --- a/docs/gpu-code-objects.md +++ b/docs/gpu-code-objects.md @@ -40,9 +40,16 @@ retaining their raw triple spelling. CUDA's legacy fatbinary compression flag is retained as an unsupported codec with its encoded and decoded extents; compressed bytes are not treated as PTX text. CCOB is recognized by the exact four signature bytes `CCOB`; high-bit-corrupted bytes are not decoded as ASCII -and accepted as a bundle. CCOB v1 has no stored total size, so a following -legacy bundle is located by the next `CCOB` signature and zero-byte alignment -padding remains part of the first container extent. +and accepted as a bundle. CCOB v1 has no stored total size: its zlib stream or +Zstandard frame supplies the encoded payload extent. Literal `CCOB` bytes inside +that stream are ordinary payload bytes. Zero-byte alignment padding between +wrappers is skipped outside the preceding payload range; v2/v3 use their declared +wrapper sizes. + +A HIP bundle target identifies the toolchain target independently of payload +representation. Recognized LLVM bitcode remains unsupported for kernel metadata +inspection; its identity, location and available digest are retained with partial +payload coverage. Corrupt recognized ELF payloads remain malformed. `cuda_target_header` retains the OS ABI, normalized raw `e_flags` and exact header location. CUDA OS ABI `0x33` and the observed `0x41` flags layout use diff --git a/src/gpu/ClangOffloadBundle.ts b/src/gpu/ClangOffloadBundle.ts index 0a42368f5b..393bb04a04 100644 --- a/src/gpu/ClangOffloadBundle.ts +++ b/src/gpu/ClangOffloadBundle.ts @@ -7,7 +7,6 @@ import { import { assertArtifactReadActive } from "../artifacts/ArtifactFileOperation.js"; import { gpuCount, - GPU_OBJECT_BYTES, GPU_REPORT_BYTES, rejectGpu, type GpuSource, @@ -118,30 +117,22 @@ export const readCcobHeader = (bytes: Buffer, remaining: number) => { return { version, method, width, total, decoded, hash }; }; -/** Legacy v1 has no size field; LLVM finds the next CCOB magic as its boundary. */ -export const readLegacyCcobTotal = async ( - source: GpuSource, - base: number, -): Promise => { - const searchEnd = Math.min(source.size, base + GPU_OBJECT_BYTES); - let position = base + 20; - let tail: Buffer = Buffer.alloc(0); - while (position < searchEnd) { - const bytes = await source.read( - position, - Math.min(64 * 1024, searchEnd - position), - ); - const haystack = tail.length === 0 ? bytes : Buffer.concat([tail, bytes]); - const index = haystack.indexOf("CCOB"); - if (index >= 0) return position - tail.length + index - base; - tail = haystack.subarray(Math.max(0, haystack.length - 3)); - position += bytes.length; +/** Recovery candidates after a failed legacy codec stream, never normal boundaries. */ +export const ccobRecoveryOffset = (bytes: Buffer): number | undefined => { + for ( + let at = bytes.indexOf(CCOB_MAGIC); + at >= 0; + at = bytes.indexOf(CCOB_MAGIC, at + 4) + ) { + try { + const header = readCcobHeader( + bytes.subarray(at, at + 32), + bytes.length - at, + ); + if (header.method <= 1) return at; + } catch (cause) { + if (!(cause instanceof BinaryFormatFailure)) throw cause; + } } - if (searchEnd < source.size) - throw new BinaryResourceFailure( - "structure", - GPU_OBJECT_BYTES, - "Legacy CCOB v1 extent exceeds the 128 MiB decoding budget", - ); - return source.size - base; + return undefined; }; diff --git a/src/gpu/GpuInventory.test.ts b/src/gpu/GpuInventory.test.ts index e98be43042..22fa645a74 100644 --- a/src/gpu/GpuInventory.test.ts +++ b/src/gpu/GpuInventory.test.ts @@ -1,12 +1,76 @@ import { expect, it } from "vitest"; +import { createHash } from "node:crypto"; +import { deflateSync } from "node:zlib"; import { GpuInventory } from "./GpuInventory.js"; import { GpuBufferSource } from "./GpuSource.js"; import { ccobFixture, cubinFixture, cudaFixture, + hipFixture, } from "../../tests/fixtures/gpu/codeObjects.js"; +it("uses the zlib stream extent for legacy CCOB even when encoded literals contain CCOB", async () => { + const bundle = hipFixture( + Buffer.from("ordinary CCOB marker"), + "host-x86_64-unknown-linux", + ); + const compressed = deflateSync(bundle, { level: 0 }); + expect(compressed.indexOf("CCOB")).toBeGreaterThan(0); + const legacyHeader = Buffer.alloc(20); + legacyHeader.write("CCOB"); + legacyHeader.writeUInt16LE(1, 4); + legacyHeader.writeUInt32LE(bundle.length, 8); + createHash("md5").update(bundle).digest().copy(legacyHeader, 12, 0, 8); + const legacy = Buffer.concat([legacyHeader, compressed]); + const following = ccobFixture(2, "zlib", bundle); + for (const bytes of [ + legacy, + Buffer.concat([legacy, Buffer.alloc(4), following]), + ]) { + const inventory = new GpuInventory({ + path: "/fixture", + analysis: "inventory", + }); + await inventory.inspect(new GpuBufferSource(bytes, "fixture")); + expect(inventory.discovery).toBe("complete"); + expect(inventory.objects[0]).toMatchObject({ + encoded_size: compressed.length, + location: { offset: 20, size: compressed.length }, + inspection: "complete", + }); + if (bytes !== legacy) + expect( + inventory.objects.filter((row) => row.kind === "hip-bundle"), + ).toHaveLength(2); + } +}); + +it.each(["hip-amdgcn-amd-amdhsa--gfx1100", "hip-amdgcn-amd-amdhsa--"])( + "keeps HIP bitcode %s unsupported with its digest and target declaration", + async (id) => { + const payload = Buffer.from("4243c0de00000000", "hex"); + const inventory = new GpuInventory({ + path: "/fixture", + analysis: "kernels", + }); + await inventory.inspect( + new GpuBufferSource(hipFixture(payload, id), "fixture"), + ); + expect(inventory.objects.at(-1)).toMatchObject({ + inspection: "unsupported", + limitations: expect.arrayContaining([ + expect.stringContaining("LLVM bitcode"), + ]), + }); + if (id.endsWith("gfx1100")) + expect(inventory.objects.at(-1)?.sha256).toBe( + createHash("sha256").update(payload).digest("hex"), + ); + expect(inventory.kernels).toEqual([]); + }, +); + it.each(["cuda", "ccob"] as const)( "refuses oversized %s output before reading compressed input", async (kind) => { diff --git a/src/gpu/GpuInventory.ts b/src/gpu/GpuInventory.ts index 8926f0e66e..ac4f0dca7d 100644 --- a/src/gpu/GpuInventory.ts +++ b/src/gpu/GpuInventory.ts @@ -1,14 +1,14 @@ import { CCOB_MAGIC, CLANG_BUNDLE_MAGIC, - readLegacyCcobTotal, readClangBundleDirectory, readCcobHeader, + ccobRecoveryOffset, } from "./ClangOffloadBundle.js"; import { gpuObject, gpuLocation, type GpuPayloadTarget } from "./GpuObject.js"; import { readCudaTarget } from "./CudaTarget.js"; import { readCudaFatbinary } from "./CudaFatbinary.js"; -import { decodeGpuPayload } from "./GpuPayloadCodec.js"; +import { decodeGpuPayload, decodeGpuPayloadStream } from "./GpuPayloadCodec.js"; import { createHash } from "node:crypto"; import { scheduler } from "node:timers/promises"; import type { @@ -53,6 +53,17 @@ import { readPtxTarget, } from "./PtxKernels.js"; +const requireAmdElfPayload = (bytes: Buffer): void => { + if ( + bytes.subarray(0, 4).equals(Buffer.from([0x42, 0x43, 0xc0, 0xde])) || + bytes.subarray(0, 4).equals(Buffer.from([0xde, 0xc0, 0x17, 0x0b])) + ) + throw new BinaryFormatFailure( + "unsupported", + "HIP LLVM bitcode payload metadata is outside kernel inspection coverage", + ); +}; + /** File declarations only. Every child range stays in its named parent coordinate space. */ export class GpuInventory { readonly objects: GpuObject[] = []; @@ -394,6 +405,9 @@ export class GpuInventory { this.kernels.push(kernel); } } else if (row.kind === "cubin" || row.kind === "amdgpu") { + // A HIP target declaration does not identify its intermediate payload + // representation. LLVM bitcode is recognizable but outside ELF coverage. + if (row.kind === "amdgpu") requireAmdElfPayload(bytes); coverage = await this.#elfFacts(decoded, row, row.kind, preparedAmd); } else { row.inspection = "unsupported"; @@ -464,6 +478,8 @@ export class GpuInventory { }; try { + if (directory === undefined) + requireAmdElfPayload(await child.read(0, Math.min(4, child.size))); const native = directory ?? (await readGpuNativeDirectory( @@ -552,12 +568,12 @@ export class GpuInventory { } if (!prefix.subarray(0, CCOB_MAGIC.length).equals(CCOB_MAGIC)) rejectGpu("Unexpected bytes between Clang offload bundles"); - const legacyTotal = - prefix.length >= 6 && prefix.readUInt16LE(4) === 1 - ? await readLegacyCcobTotal(source, base) - : source.size - base; - const header = readCcobHeader(prefix, legacyTotal), - { width, total, decoded, hash, version, method } = header; + const header = readCcobHeader(prefix, source.size - base), + { width, decoded, hash, version, method } = header; + let total = + version === 1 + ? Math.min(header.total, width + GPU_OBJECT_BYTES) + : header.total; const row = gpuObject(source, { offset: base + width, size: total - width, @@ -569,6 +585,7 @@ export class GpuInventory { row.compression_id = `ccob:${version}:${method}`; row.decoded_size = decoded; this.#add(row); + let encoded: Buffer | undefined; if (row.compression === "unsupported") { row.inspection = "unsupported"; this.#objectLimitations(row, ["Unsupported CCOB compression method"]); @@ -576,13 +593,33 @@ export class GpuInventory { } else try { this.#budgetWork(total - width, decoded); - const data = await decodeGpuPayload( - await source.read(base + width, total - width), - row.compression, - decoded, - this.signal, - version === 1, - ); + encoded = await source.read(base + width, total - width); + const decodedPayload = + version === 1 + ? await decodeGpuPayloadStream( + encoded, + row.compression, + decoded, + this.signal, + ) + : { + buffer: await decodeGpuPayload( + encoded, + row.compression, + decoded, + this.signal, + ), + consumedBytes: encoded.length, + }; + const data = decodedPayload.buffer; + if (version === 1) { + total = width + decodedPayload.consumedBytes; + row.encoded_size = decodedPayload.consumedBytes; + row.location.size = decodedPayload.consumedBytes; + } + // Recovery scanning applies only when the codec could not establish + // an extent, not to hash failures or malformed decoded children. + encoded = undefined; if ( createHash("md5").update(data).digest().readBigUInt64LE(0) !== hash ) @@ -591,6 +628,17 @@ export class GpuInventory { await this.inspect(new GpuBufferSource(data, row.id), depth + 1); row.inspection = "complete"; } catch (cause) { + if (version === 1 && encoded !== undefined) { + const recovered = ccobRecoveryOffset(encoded); + if (recovered !== undefined) { + total = width + recovered; + row.encoded_size = recovered; + row.location.size = recovered; + this.#objectLimitations(row, [ + "Legacy stream decoding failed; the next structurally recognized CCOB header is a recovery boundary, not a verified codec extent.", + ]); + } + } this.#retainFailure(row, cause); this.discovery = "partial"; } diff --git a/src/gpu/GpuPayloadCodec.test.ts b/src/gpu/GpuPayloadCodec.test.ts index 97fc039ee8..a6914907f2 100644 --- a/src/gpu/GpuPayloadCodec.test.ts +++ b/src/gpu/GpuPayloadCodec.test.ts @@ -1,6 +1,33 @@ import { expect, it } from "vitest"; import { deflateSync, zstdCompressSync } from "node:zlib"; -import { decodeGpuPayload } from "./GpuPayloadCodec.js"; +import { decodeGpuPayload, decodeGpuPayloadStream } from "./GpuPayloadCodec.js"; + +it.each(["zlib", "zstd"] as const)( + "retains the first %s stream extent without interpreting following container bytes", + async (codec) => { + const source = Buffer.from("ordinary CCOB marker"); + const encoded = + codec === "zstd" + ? zstdCompressSync(source) + : deflateSync(source, { level: 0 }); + const input = Buffer.concat([ + encoded, + Buffer.alloc(8), + Buffer.from("CCOB"), + ]); + expect(await decodeGpuPayloadStream(input, codec, source.length)).toEqual({ + buffer: source, + consumedBytes: encoded.length, + }); + await expect( + decodeGpuPayloadStream( + encoded.subarray(0, encoded.length - 1), + codec, + source.length, + ), + ).rejects.toMatchObject({ kind: "malformed" }); + }, +); it.each(["zlib", "zstd"] as const)( "requires all declared %s input bytes to belong to the codec stream", async (codec) => { diff --git a/src/gpu/GpuPayloadCodec.ts b/src/gpu/GpuPayloadCodec.ts index e84a94ad86..6e27596019 100644 --- a/src/gpu/GpuPayloadCodec.ts +++ b/src/gpu/GpuPayloadCodec.ts @@ -6,15 +6,48 @@ import { decodeLz4Block } from "./Lz4Block.js"; import { rejectGpu } from "./GpuSource.js"; const unzip = promisify(inflate); const unzstd = promisify(zstdDecompress); -export const decodeGpuPayload = async ( +/** Locate one standard Zstandard frame without interpreting compressed literals. */ +const zstdFrameBytes = (bytes: Buffer): number => { + const require = (offset: number, size: number): void => { + if (offset > bytes.length - size) + rejectGpu("GPU Zstandard frame is truncated"); + }; + require(0, 5); + if (bytes.readUInt32LE(0) !== 0xfd2fb528) + rejectGpu("GPU Zstandard frame magic is invalid"); + const descriptor = bytes[4]!; + if ((descriptor & 0x08) !== 0) + rejectGpu("GPU Zstandard frame uses a reserved header bit"); + const singleSegment = (descriptor & 0x20) !== 0; + const sizeFlag = descriptor >>> 6; + const sizeBytes = sizeFlag === 0 ? (singleSegment ? 1 : 0) : 2 ** sizeFlag; + const dictionaryFlag = descriptor & 3; + const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag; + let at = 5 + (singleSegment ? 0 : 1) + dictionaryBytes + sizeBytes; + require(0, at); + for (;;) { + require(at, 3); + const header = bytes.readUIntLE(at, 3); + const kind = (header >>> 1) & 3; + if (kind === 3) rejectGpu("GPU Zstandard block uses a reserved type"); + const length = kind === 1 ? 1 : header >>> 3; + at += 3; + require(at, length); + at += length; + if ((header & 1) !== 0) break; + } + const checksumBytes = (descriptor & 4) === 0 ? 0 : 4; + require(at, checksumBytes); + return at + checksumBytes; +}; + +const decodeGpuCodec = async ( bytes: Buffer, - compression: "zlib" | "zstd" | "lz4", + compression: "zlib" | "zstd", expected: number, signal?: AbortSignal, - allowZeroPadding = false, -): Promise => { +): Promise<{ buffer: Buffer; consumedBytes: number }> => { assertArtifactReadActive(signal); - if (compression === "lz4") return decodeLz4Block(bytes, expected, signal); let result: unknown; try { const maxOutputLength = Math.max(1, expected); @@ -32,18 +65,42 @@ export const decodeGpuPayload = async ( ); } assertArtifactReadActive(signal); - const { buffer: decoded, consumedBytes: consumed } = nodeCodecResult(result); - if ( - consumed !== bytes.length && - (!allowZeroPadding || - consumed < 0 || - consumed > bytes.length || - !bytes.subarray(consumed).every((byte) => byte === 0)) - ) - rejectGpu( - "GPU compressed payload contains bytes outside its complete codec stream", - ); - if (decoded.length !== expected) + const decoded = nodeCodecResult(result); + if (decoded.buffer.length !== expected) rejectGpu("GPU decompressed length disagrees with its declaration"); return decoded; }; + +/** Decode one framed payload, retaining its codec-owned encoded extent. */ +export const decodeGpuPayloadStream = ( + bytes: Buffer, + compression: "zlib" | "zstd", + expected: number, + signal?: AbortSignal, +): Promise<{ buffer: Buffer; consumedBytes: number }> => + decodeGpuCodec( + compression === "zstd" ? bytes.subarray(0, zstdFrameBytes(bytes)) : bytes, + compression, + expected, + signal, + ); +export const decodeGpuPayload = async ( + bytes: Buffer, + compression: "zlib" | "zstd" | "lz4", + expected: number, + signal?: AbortSignal, +): Promise => { + assertArtifactReadActive(signal); + if (compression === "lz4") return decodeLz4Block(bytes, expected, signal); + const { buffer: decoded, consumedBytes: consumed } = await decodeGpuCodec( + bytes, + compression, + expected, + signal, + ); + if (consumed !== bytes.length) + rejectGpu( + "GPU compressed payload contains bytes outside its complete codec stream", + ); + return decoded; +};