android_sdk: encode track_event body in Java; unify tracks behind PerfettoTrack Encode debug args / proto fields / flows once in Java (ProtoWriter) and hand them to the HL emit as one verbatim RawBody field, removing the per-arg/field native objects and the per-event allocation. Build on the nested-tracks PerfettoTrack handle, extending it to counter, thread-tid and static-name leaves so named / nested / counter tracks are one immutable handle that owns its native track (built once, lock-free, freed on GC). The flat usingProcessNamedTrack / usingCounterTrack helpers are sugar over it, keeping a small content cache since they build a fresh handle per emit.
diff --git a/include/perfetto/public/abi/track_event_hl_abi.h b/include/perfetto/public/abi/track_event_hl_abi.h index 2ae69d6..692d2ce 100644 --- a/include/perfetto/public/abi/track_event_hl_abi.h +++ b/include/perfetto/public/abi/track_event_hl_abi.h
@@ -47,6 +47,10 @@ PERFETTO_TE_HL_PROTO_TYPE_DOUBLE = 6, PERFETTO_TE_HL_PROTO_TYPE_FLOAT = 7, PERFETTO_TE_HL_PROTO_TYPE_CSTR_INTERNED = 8, + // Pre-serialized protobuf appended verbatim into the enclosing message (no + // field-id wrapper). Lets a caller batch-encode several fields on its own side + // and hand them over as one blob. `header.id` is ignored. + PERFETTO_TE_HL_PROTO_TYPE_RAW = 9, }; // Common header for all the proto fields. @@ -81,6 +85,14 @@ size_t len; }; +// PERFETTO_TE_HL_PROTO_TYPE_RAW +struct PerfettoTeHlProtoFieldRaw { + struct PerfettoTeHlProtoField header; + // Pre-serialized protobuf, appended verbatim (no field-id/length wrapper). + const void* buf; + size_t len; +}; + // PERFETTO_TE_HL_PROTO_TYPE_NESTED struct PerfettoTeHlProtoFieldNested { struct PerfettoTeHlProtoField header; @@ -122,6 +134,7 @@ union PerfettoTeHlProtoFieldUnion { struct PerfettoTeHlProtoFieldCstr field_cstr; struct PerfettoTeHlProtoFieldBytes field_bytes; + struct PerfettoTeHlProtoFieldRaw field_raw; struct PerfettoTeHlProtoFieldNested field_nested; struct PerfettoTeHlProtoFieldVarInt field_varint; struct PerfettoTeHlProtoFieldFixed64 field_fixed64;
diff --git a/src/android_sdk/java/host_stubs/android/os/Bundle.java b/src/android_sdk/java/host_stubs/android/os/Bundle.java new file mode 100644 index 0000000..2cbf1c4 --- /dev/null +++ b/src/android_sdk/java/host_stubs/android/os/Bundle.java
@@ -0,0 +1,24 @@ +/* + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.os; + +/** Host-JVM stub of android.os.Bundle (just enough for instrumentation args). */ +public class Bundle { + public String getString(String key) { + return null; + } +}
diff --git a/src/android_sdk/java/host_stubs/androidx/test/platform/app/InstrumentationRegistry.java b/src/android_sdk/java/host_stubs/androidx/test/platform/app/InstrumentationRegistry.java new file mode 100644 index 0000000..939d7d0 --- /dev/null +++ b/src/android_sdk/java/host_stubs/androidx/test/platform/app/InstrumentationRegistry.java
@@ -0,0 +1,28 @@ +/* + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.test.platform.app; + +import android.os.Bundle; + +/** Host-JVM stub of androidx.test's InstrumentationRegistry (no args on host). */ +public final class InstrumentationRegistry { + private InstrumentationRegistry() {} + + public static Bundle getArguments() { + return new Bundle(); + } +}
diff --git a/src/android_sdk/java/main/BUILD.gn b/src/android_sdk/java/main/BUILD.gn index be304c2..39e32d4 100644 --- a/src/android_sdk/java/main/BUILD.gn +++ b/src/android_sdk/java/main/BUILD.gn
@@ -5,11 +5,13 @@ perfetto_android_library("perfetto_trace_lib") { sources = [ + "dev/perfetto/sdk/PerfettoTrackEventEncoder.java", "dev/perfetto/sdk/PerfettoNativeMemoryCleaner.java", "dev/perfetto/sdk/PerfettoTrace.java", "dev/perfetto/sdk/PerfettoTrack.java", "dev/perfetto/sdk/PerfettoTrackEventBuilder.java", "dev/perfetto/sdk/PerfettoTrackEventExtra.java", + "dev/perfetto/sdk/ProtoWriter.java", ] deps = [ "../../../../gn:error_prone_annotations",
diff --git a/src/android_sdk/java/main/dev/perfetto/sdk/PerfettoTrackEventBuilder.java b/src/android_sdk/java/main/dev/perfetto/sdk/PerfettoTrackEventBuilder.java index 6d4b510..6c677ba 100644 --- a/src/android_sdk/java/main/dev/perfetto/sdk/PerfettoTrackEventBuilder.java +++ b/src/android_sdk/java/main/dev/perfetto/sdk/PerfettoTrackEventBuilder.java
@@ -19,20 +19,14 @@ import com.google.errorprone.annotations.CompileTimeConstant; import java.util.ArrayList; -import java.util.function.Supplier; import dev.perfetto.sdk.PerfettoNativeMemoryCleaner.AllocationStats; import dev.perfetto.sdk.PerfettoTrace.Category; -import dev.perfetto.sdk.PerfettoTrackEventExtra.Arg; import dev.perfetto.sdk.PerfettoTrackEventExtra.Counter; import dev.perfetto.sdk.PerfettoTrackEventExtra.CounterTrack; -import dev.perfetto.sdk.PerfettoTrackEventExtra.Field; -import dev.perfetto.sdk.PerfettoTrackEventExtra.FieldContainer; -import dev.perfetto.sdk.PerfettoTrackEventExtra.FieldNested; -import dev.perfetto.sdk.PerfettoTrackEventExtra.Flow; import dev.perfetto.sdk.PerfettoTrackEventExtra.NamedTrack; +import dev.perfetto.sdk.PerfettoTrackEventExtra.NestedTracks; import dev.perfetto.sdk.PerfettoTrackEventExtra.PerfettoPointer; -import dev.perfetto.sdk.PerfettoTrackEventExtra.Proto; /** Builder for Perfetto track event extras. */ public final class PerfettoTrackEventBuilder { @@ -47,89 +41,61 @@ private boolean mIsBuilt = false; private boolean mIsDebug = false; - private PerfettoTrackEventBuilder mParent; - private FieldContainer mCurrentContainer; - private PerfettoNativeMemoryCleaner mNativeMemoryCleaner; private static final PerfettoNativeMemoryCleaner.AllocationStats sNativeAllocationStats = new AllocationStats(); - private static final class ObjectsPool { - public final Pool<Field> mFieldPool; - public final Pool<FieldNested> mFieldNestedPool; - public final Pool<Proto> mProtoPool; - public final Pool<Flow> mFlowPool; - public final Pool<Flow> mTerminatingFlowPool; - - public ObjectsPool(int capacity) { - mFieldPool = new Pool<>(capacity); - mFieldNestedPool = new Pool<>(capacity); - mProtoPool = new Pool<>(capacity); - mFlowPool = new Pool<>(capacity); - mTerminatingFlowPool = new Pool<>(capacity); - } - - public void reset() { - mFieldPool.reset(); - mFieldNestedPool.reset(); - mProtoPool.reset(); - mFlowPool.reset(); - mTerminatingFlowPool.reset(); - } - } - - private static final class ObjectsCache { - public final RingBuffer<NamedTrack> mNamedTrackCache; - public final RingBuffer<CounterTrack> mCounterTrackCache; - public final RingBuffer<Arg> mArgCache; - - public ObjectsCache(int capacity) { - mNamedTrackCache = new RingBuffer<>(capacity); - mCounterTrackCache = new RingBuffer<>(capacity); - mArgCache = new RingBuffer<>(capacity); - } - } - - private static final class LazyInitObjects { - private Counter mCounter = null; - - private final PerfettoNativeMemoryCleaner mNativeMemoryCleaner; - - private LazyInitObjects(PerfettoNativeMemoryCleaner memoryCleaner) { - this.mNativeMemoryCleaner = memoryCleaner; - } - - public Counter getCounter() { - if (mCounter == null) { - mCounter = new Counter(mNativeMemoryCleaner); - } - return mCounter; - } - } - - private Pool<PerfettoTrackEventBuilder> mChildBuildersCache; - private ObjectsPool mObjectsPool; + // Content cache for the flat track sugar (usingProcessNamedTrack / + // usingCounterTrack etc.), which builds a fresh handle per emit. Keyed by + // PerfettoTrack.flatCacheKey (folds in the counter flag, so a counter and a + // same-named track never collide). The usingTrack(PerfettoTrack) path needs no + // cache -- the handle owns its native track. + private RingBuffer<NestedTracks> mTrackCache; private ObjectsCache mObjectsCache; - private LazyInitObjects mLazyInitObjects; private final boolean mIsCategoryEnabled; private ArrayList<PerfettoPointer> mPendingPointers; - private final Supplier<PerfettoTrackEventBuilder> perfettoTrackEventBuilderSupplier = - () -> new PerfettoTrackEventBuilder(true, this); - private final Supplier<FieldNested> fieldNestedSupplier = - () -> new FieldNested(mNativeMemoryCleaner); - private final Supplier<Proto> protoSupplier = () -> new Proto(mNativeMemoryCleaner); - private final Supplier<Field> fieldSupplier = () -> new Field(mNativeMemoryCleaner); - private final Supplier<Flow> flowSupplier = () -> new Flow(mNativeMemoryCleaner); + // Tracks whether the builder is between a beginProto() and its endProto(), so + // the debug-only state checks can validate that field operations are scoped to + // a proto block. Plain proto fields are written straight into the body; the + // depth count gates endNested() back-fills. + private boolean mInProto; + private int mProtoDepth; + + // Whether this event carries any interned proto field. Interned strings can't + // be Java-encoded -- their iids are assigned per-sequence by the native + // interning tables -- so addFieldWithInterning() hands them to the RawBody + // extra, which carries them as CSTR_INTERNED fields alongside the body. This + // flag makes emit() register RawBody even when the body itself is empty. + private boolean mHasInterned; + + // The TrackEvent body: debug annotations, flows, counter value and plain proto + // fields are encoded here, then handed to native as one raw proto field. Owned + // by the root builder (already thread-local), so the hot path does no per-call + // ThreadLocal lookup. Reset at the start of each event. + private ProtoWriter mBody; + + // Reused extra that copies the encoded body into its own native buffer and + // carries it to native as one raw proto field. + private PerfettoTrackEventExtra.RawBody mRawBody; + + // Single reused native counter extra, created on first use. A counter value + // has no identity to cache, so one object per (thread-local) builder suffices + // -- no pool or ring buffer. It stays native (not body-encoded) because a + // CriticalNative value-set is cheaper than routing one tiny field through the + // body. Flows differ: an event may carry several, so re-adding them would need + // a Flow pool, and they stay body-encoded instead. + private Counter mCounter; + private static final PerfettoTrackEventBuilder NO_OP_BUILDER = - new PerfettoTrackEventBuilder(/* isCategoryEnabled= */ false, /* parent= */ null); + new PerfettoTrackEventBuilder(/* isCategoryEnabled= */ false); public static final ThreadLocal<PerfettoTrackEventBuilder> sThreadLocalBuilder = ThreadLocal.withInitial( - () -> new PerfettoTrackEventBuilder(/* isCategoryEnabled= */ true, /* parent= */ null)); + () -> new PerfettoTrackEventBuilder(/* isCategoryEnabled= */ true)); public static PerfettoTrackEventBuilder newEvent( int traceType, Category category, boolean isDebug) { @@ -139,28 +105,18 @@ return NO_OP_BUILDER; } - private PerfettoTrackEventBuilder(boolean isCategoryEnabled, PerfettoTrackEventBuilder parent) { + private PerfettoTrackEventBuilder(boolean isCategoryEnabled) { mIsCategoryEnabled = isCategoryEnabled; if (!mIsCategoryEnabled) { // No fields of this builder will be used, no need to initialize them. return; } - if (parent == null) { - // We are creating a root builder which will be saved in thread local storage. - mParent = null; - mNativeMemoryCleaner = new PerfettoNativeMemoryCleaner(null); - mChildBuildersCache = new Pool<>(DEFAULT_EXTRA_CACHE_SIZE); - mObjectsPool = new ObjectsPool(DEFAULT_EXTRA_CACHE_SIZE); - mObjectsCache = new ObjectsCache(DEFAULT_EXTRA_CACHE_SIZE); - mLazyInitObjects = new LazyInitObjects(mNativeMemoryCleaner); - mPendingPointers = new ArrayList<>(DEFAULT_PENDING_POINTERS_LIST_SIZE); - } else { - // We are create a child builder for proto fields, read all cache fields from the parent. - mParent = parent; - mNativeMemoryCleaner = parent.mNativeMemoryCleaner; - readAllCacheFieldsFromParent(parent); - } - + mNativeMemoryCleaner = new PerfettoNativeMemoryCleaner(null); + mTrackCache = new RingBuffer<>(DEFAULT_EXTRA_CACHE_SIZE); + mObjectsCache = new ObjectsCache(DEFAULT_EXTRA_CACHE_SIZE); + mPendingPointers = new ArrayList<>(DEFAULT_PENDING_POINTERS_LIST_SIZE); + mBody = new ProtoWriter(); + mRawBody = new PerfettoTrackEventExtra.RawBody(mNativeMemoryCleaner); mExtra = new PerfettoTrackEventExtra(mNativeMemoryCleaner); } @@ -174,8 +130,20 @@ } mIsBuilt = true; + int bodyLen = mBody.position(); + if (bodyLen > 0 || mHasInterned) { + // mRawBody is a permanent field of this (thread-local) builder, so it can + // never be GC'd mid-emit and does not need mPendingPointers tracking + // (which exists only to keep transiently-referenced extras alive across + // the native call). Register it on the extra directly. It also carries any + // interned fields, so it must ride even when the body is empty. + mExtra.addPerfettoPointer(mRawBody); + } + // One JNI crossing: native_emit copies the heap-encoded body into the + // RawBody's native buffer (when bodyLen > 0) and emits in the same call. PerfettoTrackEventExtra.native_emit( - mTraceType, mCategory.getPtr(), mEventName, mExtra.getPtr()); + mTraceType, mCategory.getPtr(), mEventName, mExtra.getPtr(), + mRawBody.bodyPtr(), mBody.buffer(), bodyLen); } /** Initialize the builder for a new trace event. */ @@ -185,7 +153,6 @@ return this; } mIsBuilt = false; - mParent = null; mIsDebug = isDebug; updateNativeMemoryCleanerForDebug(mIsDebug); mTraceType = traceType; @@ -193,27 +160,15 @@ mEventName = ""; mExtra.reset(); - mChildBuildersCache.reset(); - mObjectsPool.reset(); mPendingPointers.clear(); - mCurrentContainer = null; + mInProto = false; + mProtoDepth = 0; + mHasInterned = false; + mBody.reset(); return this; } - private PerfettoTrackEventBuilder initChildBuilderForProto( - PerfettoTrackEventBuilder parent, FieldContainer fieldContainer) { - mIsBuilt = false; - mParent = parent; - mIsDebug = parent.mIsDebug; - updateNativeMemoryCleanerForDebug(mIsDebug); - - readAllCacheFieldsFromParent(parent); - - mCurrentContainer = fieldContainer; - return this; - } - private void updateNativeMemoryCleanerForDebug(boolean enableDebug) { // In current implementation it is possible, that the 'PerfettoTrackEventBuilder' will be // used @@ -234,14 +189,6 @@ } } - private void readAllCacheFieldsFromParent(PerfettoTrackEventBuilder parent) { - mChildBuildersCache = parent.mChildBuildersCache; - mObjectsPool = parent.mObjectsPool; - mObjectsCache = parent.mObjectsCache; - mLazyInitObjects = parent.mLazyInitObjects; - mPendingPointers = parent.mPendingPointers; - } - /** Sets the event name for the track event. */ public PerfettoTrackEventBuilder setEventName(String eventName) { mEventName = eventName; @@ -256,13 +203,7 @@ if (mIsDebug) { checkNotBuildingProto(); } - Arg arg = mObjectsCache.mArgCache.get(name.hashCode()); - if (arg == null || !arg.getName().equals(name)) { - arg = new Arg(name, mNativeMemoryCleaner); - mObjectsCache.mArgCache.put(name.hashCode(), arg); - } - arg.setValueInt64(val); - addPerfettoPointerToExtra(arg); + PerfettoTrackEventEncoder.addArg(mBody, name, val); return this; } @@ -274,13 +215,7 @@ if (mIsDebug) { checkNotBuildingProto(); } - Arg arg = mObjectsCache.mArgCache.get(name.hashCode()); - if (arg == null || !arg.getName().equals(name)) { - arg = new Arg(name, mNativeMemoryCleaner); - mObjectsCache.mArgCache.put(name.hashCode(), arg); - } - arg.setValueBool(val); - addPerfettoPointerToExtra(arg); + PerfettoTrackEventEncoder.addArg(mBody, name, val); return this; } @@ -292,13 +227,7 @@ if (mIsDebug) { checkNotBuildingProto(); } - Arg arg = mObjectsCache.mArgCache.get(name.hashCode()); - if (arg == null || !arg.getName().equals(name)) { - arg = new Arg(name, mNativeMemoryCleaner); - mObjectsCache.mArgCache.put(name.hashCode(), arg); - } - arg.setValueDouble(val); - addPerfettoPointerToExtra(arg); + PerfettoTrackEventEncoder.addArg(mBody, name, val); return this; } @@ -310,13 +239,7 @@ if (mIsDebug) { checkNotBuildingProto(); } - Arg arg = mObjectsCache.mArgCache.get(name.hashCode()); - if (arg == null || !arg.getName().equals(name)) { - arg = new Arg(name, mNativeMemoryCleaner); - mObjectsCache.mArgCache.put(name.hashCode(), arg); - } - arg.setValueString(val); - addPerfettoPointerToExtra(arg); + PerfettoTrackEventEncoder.addArg(mBody, name, val); return this; } @@ -333,9 +256,7 @@ if (mIsDebug) { checkNotBuildingProto(); } - Flow flow = mObjectsPool.mFlowPool.get(flowSupplier); - flow.setProcessFlow(id); - addPerfettoPointerToExtra(flow); + PerfettoTrackEventEncoder.addFlow(mBody, id); return this; } @@ -352,18 +273,20 @@ if (mIsDebug) { checkNotBuildingProto(); } - Flow terminatingFlow = mObjectsPool.mTerminatingFlowPool.get(flowSupplier); - terminatingFlow.setProcessTerminatingFlow(id); - addPerfettoPointerToExtra(terminatingFlow); + PerfettoTrackEventEncoder.addTerminatingFlow(mBody, id); return this; } /** * Emits this event on {@code track}, a (possibly nested) named track. The - * descriptor for each level of the chain is emitted once per sequence; the - * native side derives the per-level uuids. The handle owns its native track - * (built once on first use, reused for its lifetime), so a reused {@code track} - * -- the intended {@code static final} usage -- is allocation-free. + * descriptor for each level of the chain is emitted once per sequence. The + * native side derives the per-level uuids; nothing is hardcoded here. The + * {@link NestedTracks} wrapper is cached per {@link PerfettoTrack}, so this is + * allocation-free after the first use of a given track. + * + * <p>This is the single named/nested track entry point. The flat {@code + * usingProcessNamedTrack} / {@code usingThreadNamedTrack} helpers below are + * thin sugar over it. */ public PerfettoTrackEventBuilder usingTrack(PerfettoTrack track) { if (!mIsCategoryEnabled) { @@ -544,16 +467,19 @@ } /** Sets a long counter value on the event. */ - public PerfettoTrackEventBuilder setCounter(long val) { + + public PerfettoTrackEventBuilder setCounter(long val) { if (!mIsCategoryEnabled) { return this; } if (mIsDebug) { checkNotBuildingProto(); } - Counter counter = mLazyInitObjects.getCounter(); - counter.setValueInt64(val); - addPerfettoPointerToExtra(counter); + if (mCounter == null) { + mCounter = new Counter(mNativeMemoryCleaner); + } + mCounter.setValueInt64(val); + addPerfettoPointerToExtra(mCounter); return this; } @@ -565,9 +491,11 @@ if (mIsDebug) { checkNotBuildingProto(); } - Counter counter = mLazyInitObjects.getCounter(); - counter.setValueDouble(val); - addPerfettoPointerToExtra(counter); + if (mCounter == null) { + mCounter = new Counter(mNativeMemoryCleaner); + } + mCounter.setValueDouble(val); + addPerfettoPointerToExtra(mCounter); return this; } @@ -579,9 +507,7 @@ if (mIsDebug) { checkBuildingProto(); } - Field field = mObjectsPool.mFieldPool.get(fieldSupplier); - field.setValueInt64(id, val); - addFieldToContainer(field); + PerfettoTrackEventEncoder.protoVarInt(mBody, (int) id, val); return this; } @@ -593,9 +519,7 @@ if (mIsDebug) { checkBuildingProto(); } - Field field = mObjectsPool.mFieldPool.get(fieldSupplier); - field.setValueDouble(id, val); - addFieldToContainer(field); + PerfettoTrackEventEncoder.protoDouble(mBody, (int) id, val); return this; } @@ -607,16 +531,16 @@ if (mIsDebug) { checkBuildingProto(); } - Field field = mObjectsPool.mFieldPool.get(fieldSupplier); - field.setValueString(id, val); - addFieldToContainer(field); + PerfettoTrackEventEncoder.protoString(mBody, (int) id, val); return this; } /** - * Adds a proto field with field id { @code id} and value { @code val}. - * { @code internedTypeId} must be non-zero, in which case the string { @code val} will be interned - * with the given type ID. If { @code internedTypeId} is zero, the string is dropped silently. + * Adds a proto field with field id {@code id} whose string {@code val} is + * interned under {@code internedTypeId} (an InternedData field number). The + * string is interned natively (its iid is assigned per-sequence), riding on the + * event's raw-body extra alongside the encoded body. {@code internedTypeId} + * must be non-zero. */ public PerfettoTrackEventBuilder addFieldWithInterning(long id, String val, long internedTypeId) { if (!mIsCategoryEnabled) { @@ -625,9 +549,11 @@ if (mIsDebug) { checkBuildingProto(); } - Field field = mObjectsPool.mFieldPool.get(fieldSupplier); - field.setValueWithInterning(id, val, internedTypeId); - addFieldToContainer(field); + if (internedTypeId == 0) { + return this; + } + mRawBody.addInterned(id, val, internedTypeId); + mHasInterned = true; return this; } @@ -645,12 +571,8 @@ if (mIsDebug) { checkNotBuildingProto(); } - Proto proto = mObjectsPool.mProtoPool.get(protoSupplier); - proto.clearFields(); - addPerfettoPointerToExtra(proto); - return mChildBuildersCache - .get(perfettoTrackEventBuilderSupplier) - .initChildBuilderForProto(this, proto); + mInProto = true; + return this; } /** Ends a proto field. */ @@ -661,7 +583,8 @@ if (mIsDebug) { checkMatchingBeginProto(); } - return mParent; + mInProto = false; + return this; } /** @@ -675,12 +598,9 @@ if (mIsDebug) { checkBuildingProto(); } - FieldNested field = mObjectsPool.mFieldNestedPool.get(fieldNestedSupplier); - field.setId(id); - addFieldToContainer(field); - return mChildBuildersCache - .get(perfettoTrackEventBuilderSupplier) - .initChildBuilderForProto(this, field); + PerfettoTrackEventEncoder.protoBeginNested(mBody, (int) id); + mProtoDepth++; + return this; } /** Ends a nested proto field. */ @@ -688,24 +608,17 @@ if (!mIsCategoryEnabled) { return this; } - if (mIsDebug) { checkMatchingBeginNested(); } - - return mParent; - } - - private void addFieldToContainer(PerfettoPointer field) { - // Keep reference to the java object, `mCurrentContainer` uses a native part of the field - // object. - mPendingPointers.add(field); - mCurrentContainer.addField(field); + mProtoDepth--; + PerfettoTrackEventEncoder.protoEndNested(mBody); + return this; } private void addPerfettoPointerToExtra(PerfettoPointer arg) { - // Keep reference to the java object, `mCurrentContainer` uses a native part of the field - // object. + // Keep a reference to the Java object; the extra holds a native pointer into + // the object. mPendingPointers.add(arg); mExtra.addPerfettoPointer(arg); } @@ -748,31 +661,6 @@ } } - private static final class Pool<T> { - private final int mCapacity; - private final T[] mValueArray; - private int mIdx = 0; - - Pool(int capacity) { - mCapacity = capacity; - mValueArray = (T[]) new Object[capacity]; - } - - public void reset() { - mIdx = 0; - } - - public T get(Supplier<T> supplier) { - if (mIdx >= mCapacity) { - return supplier.get(); - } - if (mValueArray[mIdx] == null) { - mValueArray[mIdx] = supplier.get(); - } - return mValueArray[mIdx++]; - } - } - private void checkState() { if (mIsBuilt) throwStateError(); } @@ -783,26 +671,9 @@ "This builder has already been used. Create a new builder for another event."); } - private boolean isBuildingTopLevelExtra() { - return mParent == null && mCurrentContainer == null; - } - - private boolean isBuildingProto() { - return (mParent != null && mParent.isBuildingTopLevelExtra()) && mCurrentContainer != null; - } - - private boolean isBuildingNestedProto() { - return (mParent != null && (mParent.isBuildingProtoOrNestedProto())) - && mCurrentContainer != null; - } - - private boolean isBuildingProtoOrNestedProto() { - return isBuildingProto() || isBuildingNestedProto(); - } - private void checkNotBuildingProto() { checkState(); - if (isBuildingProtoOrNestedProto()) throwNotBuildingProtoError(); + if (mInProto) throwNotBuildingProtoError(); } /** Outlined to keep the caller method small and more likely to be inlined. */ @@ -812,7 +683,7 @@ private void checkBuildingProto() { checkState(); - if (isBuildingTopLevelExtra()) throwBuildingProtoError(); + if (!mInProto) throwBuildingProtoError(); } /** Outlined to keep the caller method small and more likely to be inlined. */ @@ -822,7 +693,7 @@ private void checkMatchingBeginNested() { checkState(); - if (!isBuildingNestedProto()) throwMatchingBeginNestedError(); + if (mProtoDepth == 0) throwMatchingBeginNestedError(); } /** Outlined to keep the caller method small and more likely to be inlined. */ @@ -832,11 +703,21 @@ private void checkMatchingBeginProto() { checkState(); - if (!isBuildingProto()) throwMatchingBeginProtoError(); + if (!mInProto || mProtoDepth > 0) throwMatchingBeginProtoError(); } /** Outlined to keep the caller method small and more likely to be inlined. */ private static void throwMatchingBeginProtoError() { throw new IllegalStateException("No matching beginProto call."); } + + private static final class ObjectsCache { + public final RingBuffer<NamedTrack> mNamedTrackCache; + public final RingBuffer<CounterTrack> mCounterTrackCache; + + public ObjectsCache(int capacity) { + mNamedTrackCache = new RingBuffer<>(capacity); + mCounterTrackCache = new RingBuffer<>(capacity); + } + } }
diff --git a/src/android_sdk/java/main/dev/perfetto/sdk/PerfettoTrackEventEncoder.java b/src/android_sdk/java/main/dev/perfetto/sdk/PerfettoTrackEventEncoder.java new file mode 100644 index 0000000..1a6acfc --- /dev/null +++ b/src/android_sdk/java/main/dev/perfetto/sdk/PerfettoTrackEventEncoder.java
@@ -0,0 +1,128 @@ +/* + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.perfetto.sdk; + +/** + * Encoders for the variable part of a {@code TrackEvent} -- its "body": debug + * annotations, flows, the counter value and proto fields. + * + * <p>Each method writes the corresponding protobuf field(s) into the caller's + * reused {@link ProtoWriter}. The encoded body is later handed to native as one + * verbatim raw proto field, spliced into the {@code track_event} submessage. + * Encoding the body in Java keeps these fields off the per-field native crossing + * the High Level extras would otherwise need. + * + * @hide + */ +public final class PerfettoTrackEventEncoder { + // TrackEvent field numbers. + private static final int TE_DEBUG_ANNOTATIONS = 4; + private static final int TE_FLOW_IDS = 47; + private static final int TE_TERMINATING_FLOW_IDS = 48; + + // DebugAnnotation field numbers. + private static final int DA_BOOL_VALUE = 2; + private static final int DA_INT_VALUE = 4; + private static final int DA_DOUBLE_VALUE = 5; + private static final int DA_STRING_VALUE = 6; + private static final int DA_NAME = 10; + + // Process track uuid, cached on first use; flows are xor-folded with it, + // matching PerfettoTeProcessScopedFlow in the C SDK. + private static volatile long sProcessTrackUuid; + private static volatile boolean sProcessTrackUuidValid; + + private PerfettoTrackEventEncoder() {} + + private static long processTrackUuid() { + if (!sProcessTrackUuidValid) { + sProcessTrackUuid = PerfettoTrace.getProcessTrackUuid(); + sProcessTrackUuidValid = true; + } + return sProcessTrackUuid; + } + + // All encode methods take the caller's ProtoWriter `b` (owned by the thread- + // local PerfettoTrackEventBuilder) so the hot path does no ThreadLocal lookup. + + /** Appends an int64 debug annotation to the body. */ + static void addArg(ProtoWriter b, String name, long value) { + b.beginNested(TE_DEBUG_ANNOTATIONS); + b.writeString(DA_NAME, name); + b.writeVarInt(DA_INT_VALUE, value); + b.endNested(); + } + + /** Appends a bool debug annotation to the body. */ + static void addArg(ProtoWriter b, String name, boolean value) { + b.beginNested(TE_DEBUG_ANNOTATIONS); + b.writeString(DA_NAME, name); + b.writeBool(DA_BOOL_VALUE, value); + b.endNested(); + } + + /** Appends a double debug annotation to the body. */ + static void addArg(ProtoWriter b, String name, double value) { + b.beginNested(TE_DEBUG_ANNOTATIONS); + b.writeString(DA_NAME, name); + b.writeDouble(DA_DOUBLE_VALUE, value); + b.endNested(); + } + + /** Appends a string debug annotation to the body. */ + static void addArg(ProtoWriter b, String name, String value) { + b.beginNested(TE_DEBUG_ANNOTATIONS); + b.writeString(DA_NAME, name); + b.writeString(DA_STRING_VALUE, value); + b.endNested(); + } + + /** Appends a (process-scoped) flow id to the body. */ + static void addFlow(ProtoWriter b, long id) { + b.writeFixed64(TE_FLOW_IDS, id ^ processTrackUuid()); + } + + /** Appends a (process-scoped) terminating flow id to the body. */ + static void addTerminatingFlow(ProtoWriter b, long id) { + b.writeFixed64(TE_TERMINATING_FLOW_IDS, id ^ processTrackUuid()); + } + + /** Appends a varint proto field to the body (for beginProto/addField). */ + static void protoVarInt(ProtoWriter b, int fieldId, long value) { + b.writeVarInt(fieldId, value); + } + + /** Appends a double proto field to the body. */ + static void protoDouble(ProtoWriter b, int fieldId, double value) { + b.writeDouble(fieldId, value); + } + + /** Appends a string proto field to the body. */ + static void protoString(ProtoWriter b, int fieldId, String value) { + b.writeString(fieldId, value); + } + + /** Begins a nested proto message in the body. */ + static void protoBeginNested(ProtoWriter b, int fieldId) { + b.beginNested(fieldId); + } + + /** Ends the innermost nested proto message started with {@link #protoBeginNested}. */ + static void protoEndNested(ProtoWriter b) { + b.endNested(); + } +}
diff --git a/src/android_sdk/java/main/dev/perfetto/sdk/PerfettoTrackEventExtra.java b/src/android_sdk/java/main/dev/perfetto/sdk/PerfettoTrackEventExtra.java index c9faaca..42852da 100644 --- a/src/android_sdk/java/main/dev/perfetto/sdk/PerfettoTrackEventExtra.java +++ b/src/android_sdk/java/main/dev/perfetto/sdk/PerfettoTrackEventExtra.java
@@ -61,7 +61,8 @@ private static native void native_clear_args(long ptr); @FastNative - public static native void native_emit(int type, long tag, String name, long ptr); + public static native void native_emit( + int type, long tag, String name, long ptr, long rawBodyPtr, byte[] body, int bodyLen); /** Represents a native pointer to a Perfetto C SDK struct. E.g. PerfettoTeHlExtra. */ interface PerfettoPointer { @@ -474,4 +475,51 @@ @CriticalNative private static native void native_set_id(long ptr, long id); } + + static final class RawBody implements PerfettoPointer { + private final long mPtr; + private final long mExtraPtr; + + RawBody(PerfettoNativeMemoryCleaner memoryCleaner) { + mPtr = native_init(); + mExtraPtr = native_get_extra_ptr(mPtr); + memoryCleaner.registerNativeAllocation(this, mPtr, native_delete()); + } + + @Override + public long getPtr() { + return mExtraPtr; + } + + /** + * Native RawBody pointer. The body bytes are copied into this object's + * buffer inside the {@code native_emit} call (one JNI crossing for copy and + * emit together), so there is no separate set-body crossing. + */ + long bodyPtr() { + return mPtr; + } + + /** + * Adds an interned string proto field that rides alongside the body and is + * interned natively at emit time (its iid is per-sequence native state the + * verbatim body can't carry). + */ + void addInterned(long id, String val, long internedTypeId) { + native_add_interned(mPtr, id, val, internedTypeId); + } + + @CriticalNative + private static native long native_init(); + + @FastNative + private static native void native_add_interned( + long ptr, long id, String val, long internedTypeId); + + @CriticalNative + private static native long native_delete(); + + @CriticalNative + private static native long native_get_extra_ptr(long ptr); + } }
diff --git a/src/android_sdk/java/main/dev/perfetto/sdk/ProtoWriter.java b/src/android_sdk/java/main/dev/perfetto/sdk/ProtoWriter.java new file mode 100644 index 0000000..88e4e44 --- /dev/null +++ b/src/android_sdk/java/main/dev/perfetto/sdk/ProtoWriter.java
@@ -0,0 +1,282 @@ +/* + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.perfetto.sdk; + +/** + * Zero-allocation protobuf encoder. Pure-Java equivalent of C protozero. + * + * <p>Writes protobuf wire format directly into a pre-allocated byte buffer. + * Designed for encoding Perfetto trace packets on the frame-rendering hot path, + * where allocation and GC pressure are unacceptable. + * + * <p>Each public {@code write*} call does exactly one {@link #ensureCapacity} + * (sized for tag + worst-case payload) and then writes the tag and payload via + * {@code *NoCheck} helpers that hoist {@link #mBuf}/{@link #mPos} into locals. + * This keeps the per-field cost to a single bounds/grow check and one field + * write-back, which matters on runtimes that don't inline aggressively. + * + * <p>Nested messages use a 4-byte redundant varint for the length prefix, + * matching protozero's single-pass strategy: the length is reserved before the + * submessage body is written and back-filled on {@link #endNested}, so sizes + * never need to be pre-computed. The redundant form encodes small values in a + * fixed 4 bytes (e.g. 5 becomes {@code 0x85 0x80 0x80 0x00}); this is valid + * protobuf that every conformant decoder accepts. + * + * <p>Thread safety: not thread-safe. Each thread must use its own instance + * (typically held in a {@link ThreadLocal}). + * + * @hide + */ +public final class ProtoWriter { + private static final int WIRE_TYPE_VARINT = 0; + private static final int WIRE_TYPE_FIXED64 = 1; + private static final int WIRE_TYPE_DELIMITED = 2; + + // Worst-case encoded sizes, used to size a single ensureCapacity per field. + private static final int MAX_VARINT_LEN = 10; // a 64-bit varint + private static final int MAX_TAG_LEN = 5; // a field tag varint (field nums to 2^28) + + // Matches PROTOZERO_MESSAGE_LENGTH_FIELD_SIZE in pb_msg.h. A nested message + // can therefore be up to 0x0FFFFFFF bytes (256MB), the protozero limit. + private static final int NESTED_LENGTH_FIELD_SIZE = 4; + private static final int MAX_NESTING_DEPTH = 16; + private static final int UTF8_SCRATCH_SIZE = 512; + // Small by default (a track-event body is well under this); grows on demand. + // Kept small so that hundreds of per-thread writers stay cheap. + private static final int DEFAULT_BUFFER_SIZE = 512; + + private byte[] mBuf; + private int mPos; + private final int[] mNestingStack = new int[MAX_NESTING_DEPTH]; + private int mNestingDepth; + private final byte[] mUtf8Scratch = new byte[UTF8_SCRATCH_SIZE]; + + public ProtoWriter() { + this(DEFAULT_BUFFER_SIZE); + } + + public ProtoWriter(int bufferSize) { + mBuf = new byte[bufferSize]; + } + + /** Resets the write position so the buffer can be reused. No allocation. */ + public void reset() { + mPos = 0; + mNestingDepth = 0; + } + + /** Number of bytes written so far. */ + public int position() { + return mPos; + } + + /** The backing buffer. Valid data spans {@code [0, position())}. */ + public byte[] buffer() { + return mBuf; + } + + /** Writes a uint32/uint64/int32/int64/enum field. */ + public void writeVarInt(int fieldId, long value) { + ensureCapacity(MAX_TAG_LEN + MAX_VARINT_LEN); + putVarIntNoCheck(makeTag(fieldId, WIRE_TYPE_VARINT)); + putVarIntNoCheck(value); + } + + /** Writes a bool field. */ + public void writeBool(int fieldId, boolean value) { + ensureCapacity(MAX_TAG_LEN + 1); + putVarIntNoCheck(makeTag(fieldId, WIRE_TYPE_VARINT)); + mBuf[mPos++] = (byte) (value ? 1 : 0); + } + + /** Writes a fixed64/sfixed64 field. */ + public void writeFixed64(int fieldId, long value) { + ensureCapacity(MAX_TAG_LEN + 8); + putVarIntNoCheck(makeTag(fieldId, WIRE_TYPE_FIXED64)); + putLongLeNoCheck(value); + } + + /** Writes a double field. */ + public void writeDouble(int fieldId, double value) { + writeFixed64(fieldId, Double.doubleToRawLongBits(value)); + } + + /** + * Writes a string field. Uses an ASCII fast path when every char is {@code <= + * 0x7F} (the common case for event names, categories and arg keys), falling + * back to UTF-8 encoding otherwise. + */ + public void writeString(int fieldId, String value) { + int len = value.length(); + // tag + length varint + the bytes, ensured in one shot. ASCII is the + // overwhelmingly common case (event names, categories, arg keys), where the + // byte length equals the char length: write that length, then check-and-copy + // each char in a SINGLE pass (the previous code did one pass to verify ASCII + // and a second to copy). There is no alloc-free bulk String->byte[] copy on + // ART -- libcore's String.getBytes is itself a charAt loop, and + // getBytes(charset) allocates -- so one combined pass over the chars is the + // floor. A non-ASCII char rewinds and re-encodes the whole field as UTF-8. + ensureCapacity(MAX_TAG_LEN + MAX_VARINT_LEN + len); + int start = mPos; + putVarIntNoCheck(makeTag(fieldId, WIRE_TYPE_DELIMITED)); + putVarIntNoCheck(len); + byte[] buf = mBuf; + int pos = mPos; + for (int i = 0; i < len; i++) { + char c = value.charAt(i); + if (c > 0x7F) { + mPos = start; // discard the optimistic tag/len/bytes; redo as UTF-8. + writeStringUtf8(fieldId, value); + return; + } + buf[pos++] = (byte) c; + } + mPos = pos; + } + + /** + * Begins a nested length-delimited message. Writes the field tag and reserves + * {@link #NESTED_LENGTH_FIELD_SIZE} bytes for the length, back-filled by the + * matching {@link #endNested}. Nesting is strictly LIFO. + */ + public void beginNested(int fieldId) { + ensureCapacity(MAX_TAG_LEN + NESTED_LENGTH_FIELD_SIZE); + putVarIntNoCheck(makeTag(fieldId, WIRE_TYPE_DELIMITED)); + mNestingStack[mNestingDepth++] = mPos; + mPos += NESTED_LENGTH_FIELD_SIZE; + } + + /** + * Ends the innermost nested message started with {@link #beginNested}, back- + * filling its reserved 4-byte redundant varint with the body length. The + * redundant form keeps the prefix a fixed width regardless of the value, + * avoiding a second pass to compute sizes. + */ + public void endNested() { + int bookmark = mNestingStack[--mNestingDepth]; + int size = mPos - bookmark - NESTED_LENGTH_FIELD_SIZE; + byte[] buf = mBuf; + buf[bookmark] = (byte) ((size & 0x7F) | 0x80); + buf[bookmark + 1] = (byte) (((size >> 7) & 0x7F) | 0x80); + buf[bookmark + 2] = (byte) (((size >> 14) & 0x7F) | 0x80); + buf[bookmark + 3] = (byte) ((size >> 21) & 0x7F); + } + + // The *NoCheck writers assume the caller has already ensured capacity for the + // bytes they write; they hoist mBuf/mPos to locals. + + private static long makeTag(int fieldId, int wireType) { + return ((long) fieldId << 3) | wireType; + } + + private void putVarIntNoCheck(long value) { + byte[] buf = mBuf; + int pos = mPos; + while ((value & ~0x7FL) != 0) { + buf[pos++] = (byte) ((value & 0x7F) | 0x80); + value >>>= 7; + } + buf[pos++] = (byte) value; + mPos = pos; + } + + private void putLongLeNoCheck(long value) { + byte[] buf = mBuf; + int pos = mPos; + buf[pos] = (byte) value; + buf[pos + 1] = (byte) (value >> 8); + buf[pos + 2] = (byte) (value >> 16); + buf[pos + 3] = (byte) (value >> 24); + buf[pos + 4] = (byte) (value >> 32); + buf[pos + 5] = (byte) (value >> 40); + buf[pos + 6] = (byte) (value >> 48); + buf[pos + 7] = (byte) (value >> 56); + mPos = pos + 8; + } + + private void ensureCapacity(int needed) { + if (mPos + needed <= mBuf.length) { + return; + } + int newSize = Math.max(mBuf.length * 2, mPos + needed); + byte[] newBuf = new byte[newSize]; + System.arraycopy(mBuf, 0, newBuf, 0, mPos); + mBuf = newBuf; + } + + private void writeStringUtf8(int fieldId, String s) { + int utf8Len = encodeUtf8(s, mUtf8Scratch); + byte[] src; + int srcLen; + if (utf8Len >= 0) { + src = mUtf8Scratch; + srcLen = utf8Len; + } else { + // Scratch too small (long non-ASCII string). Rare; allocate exactly once. + src = new byte[-utf8Len]; + srcLen = encodeUtf8(s, src); + } + ensureCapacity(MAX_TAG_LEN + MAX_VARINT_LEN + srcLen); + putVarIntNoCheck(makeTag(fieldId, WIRE_TYPE_DELIMITED)); + putVarIntNoCheck(srcLen); + System.arraycopy(src, 0, mBuf, mPos, srcLen); + mPos += srcLen; + } + + /** + * Encodes {@code s} as UTF-8 into {@code dst}. Returns the byte count on + * success, or {@code -needed} (a negative lower bound on the required size) if + * {@code dst} is too small. + */ + private static int encodeUtf8(String s, byte[] dst) { + int len = s.length(); + int dp = 0; + for (int i = 0; i < len; i++) { + char c = s.charAt(i); + if (c <= 0x7F) { + if (dp >= dst.length) { + return -(len * 3); + } + dst[dp++] = (byte) c; + } else if (c <= 0x7FF) { + if (dp + 2 > dst.length) { + return -(len * 3); + } + dst[dp++] = (byte) (0xC0 | (c >> 6)); + dst[dp++] = (byte) (0x80 | (c & 0x3F)); + } else if (Character.isHighSurrogate(c) && i + 1 < len) { + char low = s.charAt(++i); + int cp = Character.toCodePoint(c, low); + if (dp + 4 > dst.length) { + return -(len * 4); + } + dst[dp++] = (byte) (0xF0 | (cp >> 18)); + dst[dp++] = (byte) (0x80 | ((cp >> 12) & 0x3F)); + dst[dp++] = (byte) (0x80 | ((cp >> 6) & 0x3F)); + dst[dp++] = (byte) (0x80 | (cp & 0x3F)); + } else { + if (dp + 3 > dst.length) { + return -(len * 3); + } + dst[dp++] = (byte) (0xE0 | (c >> 12)); + dst[dp++] = (byte) (0x80 | ((c >> 6) & 0x3F)); + dst[dp++] = (byte) (0x80 | (c & 0x3F)); + } + } + return dp; + } +}
diff --git a/src/android_sdk/java/test/dev/perfetto/sdk/test/PerfettoTraceTest.java b/src/android_sdk/java/test/dev/perfetto/sdk/test/PerfettoTraceTest.java index 46399dd..1ce3db7 100644 --- a/src/android_sdk/java/test/dev/perfetto/sdk/test/PerfettoTraceTest.java +++ b/src/android_sdk/java/test/dev/perfetto/sdk/test/PerfettoTraceTest.java
@@ -25,7 +25,6 @@ import android.util.ArraySet; import android.util.Log; import androidx.test.ext.junit.runners.AndroidJUnit4; -import dev.perfetto.sdk.PerfettoNativeMemoryCleaner.AllocationStats; import dev.perfetto.sdk.PerfettoTrace; import dev.perfetto.sdk.PerfettoTrack; import dev.perfetto.sdk.PerfettoTrackEventBuilder; @@ -84,8 +83,6 @@ // 'var unused' suppress error-prone warning var unused = FOO_CATEGORY.register(); - PerfettoTrackEventBuilder.getNativeAllocationStats().reset(); - mCategoryNames.clear(); mEventNames.clear(); mDebugAnnotationNames.clear(); @@ -93,42 +90,6 @@ } @Test - public void testFreeNativeMemoryWhenJavaObjectGCed() { - TraceConfig traceConfig = getTraceConfig(FOO); - PerfettoTrace.Session session = new PerfettoTrace.Session(true, traceConfig.toByteArray()); - for (int i = 0; i < 600_000; i++) { - String eventName = "event_" + i; - String nativeStringArgKey = "string_key_" + i; - String nativeStringValue = "string_value_" + i; - // Create a large amount of 'ArgString' objects in heap to trigger GC, no need to emit them. - PerfettoTrace.instant(FOO_CATEGORY, eventName).addArg(nativeStringArgKey, nativeStringValue); - } - - // Manually trigger GC if creating 600_000 objects was not enough. - for (int i = 0; i < 10; i++) { - System.runFinalization(); - System.gc(); - } - - // We ignore the trace content. - byte[] traceBytes = session.close(); - assertThat(traceBytes).isNotEmpty(); - - // We test that the GC triggers 'free native memory' function when the corresponding java - // objects are garbage collected. - AllocationStats allocationStats = PerfettoTrackEventBuilder.getNativeAllocationStats(); - String argClsName = "dev.perfetto.sdk.PerfettoTrackEventExtra$Arg"; - assertThat(allocationStats.getAllocCountForTarget(argClsName)).isEqualTo(600_000); - // Assert that the native memory was freed at least once. - // In practice the counter is usually greater than 300_000 if not manually trigger GC, - // and 599_995 (600_000 - dev.perfetto.sdk.PerfettoTrackEventBuilder#DEFAULT_EXTRA_CACHE_SIZE) - // if do manually trigger. - assertThat(allocationStats.getFreeCountForTarget(argClsName)).isGreaterThan(0); - String allocDebugStats = allocationStats.reportStats(); - Log.d(TAG, "Memory cleaner allocation stats: " + allocDebugStats); - } - - @Test public void testCategoryWithTags() throws Exception { Category category = new Category("MyCategory", List.of("MyTag", "MyOtherTag")).register(); TraceConfig traceConfig = getTraceConfig(null, List.of("MyTag")); @@ -224,11 +185,11 @@ PerfettoTrace.Session session = new PerfettoTrace.Session(true, traceConfig.toByteArray()); PerfettoTrace.begin(FOO_CATEGORY, "event") - .usingNamedTrack(123, FOO, PerfettoTrace.getProcessTrackUuid()) + .usingProcessNamedTrack(123, FOO) .emit(); PerfettoTrace.end(FOO_CATEGORY) - .usingNamedTrack(456, "bar", PerfettoTrace.getThreadTrackUuid(Process.myTid())) + .usingThreadNamedTrack(456, "bar", Process.myTid()) .emit(); Trace trace = Trace.parseFrom(session.close()); @@ -303,6 +264,49 @@ } @Test + public void testThreeLevelNestedTrack() throws Exception { + TraceConfig traceConfig = getTraceConfig(FOO); + + PerfettoTrace.Session session = new PerfettoTrace.Session(true, traceConfig.toByteArray()); + + PerfettoTrack a = PerfettoTrack.process("A"); + PerfettoTrack b = a.child("B"); + PerfettoTrack c = b.child("C"); + PerfettoTrace.instant(FOO_CATEGORY, "event").usingTrack(c).emit(); + + Trace trace = Trace.parseFrom(session.close()); + + Map<Long, TrackDescriptor> descriptorsByUuid = new HashMap<>(); + long eventTrackUuid = 0; + for (TracePacket packet : trace.getPacketList()) { + if (packet.hasTrackDescriptor()) { + TrackDescriptor td = packet.getTrackDescriptor(); + descriptorsByUuid.put(td.getUuid(), td); + } + if (packet.hasTrackEvent() + && TrackEvent.Type.TYPE_INSTANT.equals(packet.getTrackEvent().getType()) + && packet.getTrackEvent().hasTrackUuid()) { + eventTrackUuid = packet.getTrackEvent().getTrackUuid(); + } + } + + // Walk the chain leaf -> root: C -> B -> A -> process track. + TrackDescriptor cTd = descriptorsByUuid.get(eventTrackUuid); + assertThat(cTd).isNotNull(); + assertThat(cTd.getName()).isEqualTo("C"); + + TrackDescriptor bTd = descriptorsByUuid.get(cTd.getParentUuid()); + assertThat(bTd).isNotNull(); + assertThat(bTd.getName()).isEqualTo("B"); + + TrackDescriptor aTd = descriptorsByUuid.get(bTd.getParentUuid()); + assertThat(aTd).isNotNull(); + assertThat(aTd.getName()).isEqualTo("A"); + + assertThat(aTd.getParentUuid()).isEqualTo(PerfettoTrace.getProcessTrackUuid()); + } + + @Test public void testGlobalNestedTrack() throws Exception { TraceConfig traceConfig = getTraceConfig(FOO); @@ -314,7 +318,6 @@ Trace trace = Trace.parseFrom(session.close()); - // Index every track descriptor by its uuid and capture the event's track. Map<Long, TrackDescriptor> descriptorsByUuid = new HashMap<>(); long eventTrackUuid = 0; for (TracePacket packet : trace.getPacketList()) { @@ -541,11 +544,11 @@ PerfettoTrace.Session session = new PerfettoTrace.Session(true, traceConfig.toByteArray()); PerfettoTrace.counter(FOO_CATEGORY, 16) - .usingCounterTrack(PerfettoTrace.getProcessTrackUuid(), FOO) + .usingProcessCounterTrack(FOO) .emit(); PerfettoTrace.counter(FOO_CATEGORY, 3.14) - .usingCounterTrack(PerfettoTrace.getThreadTrackUuid(Process.myTid()), "bar") + .usingThreadCounterTrack(Process.myTid(), "bar") .emit(); Trace trace = Trace.parseFrom(session.close()); @@ -1008,6 +1011,17 @@ } private void collectInternedData(TracePacket packet) { + // Debug-arg names are written inline (DebugAnnotation.name), not interned -- + // the body is encoded in Java before emit, so it can't know the per-sequence + // name_iid. Collect the inline names from the track event itself. + if (packet.hasTrackEvent()) { + for (DebugAnnotation dbg : packet.getTrackEvent().getDebugAnnotationsList()) { + if (!dbg.getName().isEmpty()) { + mDebugAnnotationNames.add(dbg.getName()); + } + } + } + if (!packet.hasInternedData()) { return; }
diff --git a/src/android_sdk/jni/BUILD.gn b/src/android_sdk/jni/BUILD.gn index f52387e..74424c0 100644 --- a/src/android_sdk/jni/BUILD.gn +++ b/src/android_sdk/jni/BUILD.gn
@@ -10,6 +10,7 @@ "dev_perfetto_sdk_PerfettoTrackEventExtra.cc", "dev_perfetto_sdk_PerfettoTrackEventExtra.h", "macros.h", + "string_buffer.h", ] #TODO(ktimofeev): use builtin 'nativehelper' lib when building in AOSP.
diff --git a/src/android_sdk/jni/dev_perfetto_sdk_PerfettoTrackEventExtra.cc b/src/android_sdk/jni/dev_perfetto_sdk_PerfettoTrackEventExtra.cc index d57199e..26fc504 100644 --- a/src/android_sdk/jni/dev_perfetto_sdk_PerfettoTrackEventExtra.cc +++ b/src/android_sdk/jni/dev_perfetto_sdk_PerfettoTrackEventExtra.cc
@@ -498,14 +498,60 @@ jint type, jlong cat_ptr, jstring name, - jlong extra_ptr) { + jlong extra_ptr, + jlong raw_body_ptr, + jbyteArray body, + jint body_len) { + auto* raw_body = toPointer<sdk_for_jni::RawBody>(raw_body_ptr); + if (body_len > 0) { + uint8_t* dst = raw_body->reserve_body(static_cast<size_t>(body_len)); + env->GetByteArrayRegion(body, 0, body_len, reinterpret_cast<jbyte*>(dst)); + } sdk_for_jni::Category* category = toPointer<sdk_for_jni::Category>(cat_ptr); trace_event(type, category->get(), StringBuffer::utf16_to_ascii(env, name).data(), toPointer<sdk_for_jni::Extra>(extra_ptr)); StringBuffer::reset(); + raw_body->reset_after_emit(); } +static jlong dev_perfetto_sdk_PerfettoTrackEventExtraRawBody_init( + PERFETTO_JNI_HOST_PARAMS) { + return toJLong(new sdk_for_jni::RawBody()); +} + +static jlong dev_perfetto_sdk_PerfettoTrackEventExtraRawBody_delete( + PERFETTO_JNI_HOST_PARAMS) { + return toJLong(&sdk_for_jni::RawBody::delete_raw_body); +} + +static jlong dev_perfetto_sdk_PerfettoTrackEventExtraRawBody_get_extra_ptr( + PERFETTO_JNI_HOST_PARAMS_COMMA jlong ptr) { + return toJLong(toPointer<sdk_for_jni::RawBody>(ptr)->get()); +} + +static void dev_perfetto_sdk_PerfettoTrackEventExtraRawBody_add_interned( + JNIEnv* env, + jclass, + jlong ptr, + jlong id, + jstring val, + jlong interned_type_id) { + toPointer<sdk_for_jni::RawBody>(ptr)->add_interned( + static_cast<uint32_t>(id), StringBuffer::utf16_to_ascii(env, val).data(), + static_cast<uint32_t>(interned_type_id)); +} + +static const JNINativeMethod gRawBodyMethods[] = { + {"native_init", "()J", + (void*)dev_perfetto_sdk_PerfettoTrackEventExtraRawBody_init}, + {"native_add_interned", "(JJLjava/lang/String;J)V", + (void*)dev_perfetto_sdk_PerfettoTrackEventExtraRawBody_add_interned}, + {"native_delete", "()J", + (void*)dev_perfetto_sdk_PerfettoTrackEventExtraRawBody_delete}, + {"native_get_extra_ptr", "(J)J", + (void*)dev_perfetto_sdk_PerfettoTrackEventExtraRawBody_get_extra_ptr}}; + static jlong dev_perfetto_sdk_PerfettoTrackEventExtraProto_init( PERFETTO_JNI_HOST_PARAMS) { return toJLong(new sdk_for_jni::Proto()); @@ -544,7 +590,7 @@ (void*)dev_perfetto_sdk_PerfettoTrackEventExtra_add_arg}, {"native_clear_args", "(J)V", (void*)dev_perfetto_sdk_PerfettoTrackEventExtra_clear_args}, - {"native_emit", "(IJLjava/lang/String;J)V", + {"native_emit", "(IJLjava/lang/String;JJ[BI)V", (void*)dev_perfetto_sdk_PerfettoTrackEventExtra_emit}}; static const JNINativeMethod gProtoMethods[] = { @@ -723,6 +769,12 @@ TO_MAYBE_JAR_JAR_CLASS_NAME( "dev/perfetto/sdk/PerfettoTrackEventExtra$CounterTrack"), gCounterTrackMethods, NELEM(gCounterTrackMethods)); + res = jniRegisterNativeMethods( + env, + TO_MAYBE_JAR_JAR_CLASS_NAME( + "dev/perfetto/sdk/PerfettoTrackEventExtra$RawBody"), + gRawBodyMethods, NELEM(gRawBodyMethods)); + LOG_ALWAYS_FATAL_IF(res < 0, "Unable to register raw body native methods."); LOG_ALWAYS_FATAL_IF(res < 0, "Unable to register counter track native methods.");
diff --git a/src/android_sdk/jni/string_buffer.h b/src/android_sdk/jni/string_buffer.h new file mode 100644 index 0000000..0b8e930 --- /dev/null +++ b/src/android_sdk/jni/string_buffer.h
@@ -0,0 +1,155 @@ +/* + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef SRC_ANDROID_SDK_JNI_STRING_BUFFER_H_ +#define SRC_ANDROID_SDK_JNI_STRING_BUFFER_H_ + +#include <jni.h> + +#include <algorithm> +#include <list> +#include <string> +#include <string_view> + +namespace perfetto { +namespace jni { + +/** + * @brief A thread-safe utility class for converting Java UTF-16 strings to + * ASCII in a JNI environment. + * + * StringBuffer provides efficient conversion of Java strings to ASCII with + * optimized memory handling. It uses a two-tiered buffering strategy: + * 1. A fast path using pre-allocated thread-local buffers for strings up to 128 + * characters. + * 2. A fallback path using dynamic allocation for longer strings. + * + * Non-ASCII characters (>255) are replaced with '?' during conversion. The + * class maintains thread safety through thread-local storage and provides + * zero-copy string views for optimal performance. + * + * Crucially, conversions allocate no per-call native memory in the common case, + * unlike GetStringUTFChars/ScopedUtfChars. All views returned within a single + * trace event remain valid until reset() is called (typically right after the + * event is emitted). + * + * Thread Safety: All methods are thread-safe due to thread-local storage. + */ +class StringBuffer { + private: + static constexpr size_t BASE_SIZE = 128; + // Temporarily stores the UTF-16 characters retrieved from the Java + // string before they are converted to ASCII. + static thread_local inline char char_buffer[BASE_SIZE]; + // For fast-path conversions when the resulting ASCII string fits within + // the pre-allocated space. All ascii strings in a trace event will be stored + // here until emitted. + static thread_local inline jchar jchar_buffer[BASE_SIZE]; + // When the fast-path conversion is not possible (because char_buffer + // doesn't have enough space), the converted ASCII string is stored + // in this list. We use list here to avoid moving the strings on resize + // with vector. This way, we can give out string_views from the stored + // strings. The additional overhead from list node allocations is fine cos we + // are already in an extremely unlikely path here and there are other bigger + // problems if here. + static thread_local inline std::list<std::string> overflow_strings; + // current offset into the char_buffer. + static thread_local inline size_t current_offset{0}; + // This allows us avoid touching the overflow_strings directly in the fast + // path. Touching it causes some thread local init routine to run which shows + // up in profiles. + static thread_local inline bool is_overflow_strings_empty = true; + + static void copy_utf16_to_ascii(const jchar* src, + size_t len, + char* dst, + JNIEnv* env, + jstring str) { + std::transform(src, src + len, dst, [](jchar c) { + return (c <= 0xFF) ? static_cast<char>(c) : '?'; + }); + + if (src != jchar_buffer) { + // We hit the slow path to populate src, so we have to release. + env->ReleaseStringCritical(str, src); + } + } + + public: + static void reset() { + if (!is_overflow_strings_empty) { + overflow_strings.clear(); + is_overflow_strings_empty = true; + } + current_offset = 0; + } + + // Converts a Java string (jstring) to an ASCII string_view. Characters + // outside the ASCII range (0-255) are replaced with '?'. + // + // @param env The JNI environment. + // @param val The Java string to convert. + // @return A string_view representing the ASCII version of the string. + // Returns an empty string_view if the input is null or empty. + static std::string_view utf16_to_ascii(JNIEnv* env, jstring val) { + if (!val) + return ""; + + const jsize len = env->GetStringLength(val); + if (len == 0) + return ""; + + const jchar* temp_buffer; + + // Fast path: Enough space in jchar_buffer + if (static_cast<size_t>(len) <= BASE_SIZE) { + env->GetStringRegion(val, 0, len, jchar_buffer); + temp_buffer = jchar_buffer; + } else { + // Slow path: Fallback to asking ART for the string which will likely + // allocate and return a copy. + temp_buffer = env->GetStringCritical(val, nullptr); + } + + const size_t next_offset = current_offset + static_cast<size_t>(len) + 1; + // Fast path: Enough space in char_buffer + if (BASE_SIZE > next_offset) { + copy_utf16_to_ascii(temp_buffer, static_cast<size_t>(len), + char_buffer + current_offset, env, val); + char_buffer[current_offset + static_cast<size_t>(len)] = '\0'; + + auto res = + std::string_view(char_buffer + current_offset, static_cast<size_t>(len)); + current_offset = next_offset; + return res; + } else { + // Slow path: Not enough space in char_buffer. Use overflow_strings. + // This will cause a string alloc but should be very unlikely to hit. + std::string& str = + overflow_strings.emplace_back(static_cast<size_t>(len) + 1, '\0'); + + copy_utf16_to_ascii(temp_buffer, static_cast<size_t>(len), str.data(), env, + val); + is_overflow_strings_empty = false; + return std::string_view(str); + } + } +}; + +} // namespace jni +} // namespace perfetto + +#endif // SRC_ANDROID_SDK_JNI_STRING_BUFFER_H_
diff --git a/src/android_sdk/perfetto_sdk_for_jni/tracing_sdk.h b/src/android_sdk/perfetto_sdk_for_jni/tracing_sdk.h index 34547cd..b4c532d 100644 --- a/src/android_sdk/perfetto_sdk_for_jni/tracing_sdk.h +++ b/src/android_sdk/perfetto_sdk_for_jni/tracing_sdk.h
@@ -261,6 +261,90 @@ PerfettoTeHlExtraCounterUnion counter_; }; +// The single extra carrying everything the Java side encodes for a track event: +// the pre-serialized body (debug args, flows, counter value, plain proto fields) +// spliced in verbatim as one RAW proto field, plus any interned string fields. +// All ride one PROTO_FIELDS extra. The body bytes are copied once, on the +// Java->native crossing, into this object's own native buffer under a @FastNative +// method (which skips the JNI thread-state transition that dominates a small +// copy). Interned fields can't live in the verbatim body -- their iid is +// per-sequence native state -- so they are carried as CSTR_INTERNED fields here +// and interned natively at emit time, alongside the raw body. +class RawBody { + public: + RawBody() { + raw_.header.type = PERFETTO_TE_HL_PROTO_TYPE_RAW; + raw_.header.id = 0; + raw_.buf = nullptr; + raw_.len = 0; + proto_.header.type = PERFETTO_TE_HL_EXTRA_TYPE_PROTO_FIELDS; + rebuild(); + } + + // Ensures the native buffer can hold len bytes, sets raw_.len, and returns a + // writable pointer the JNI copy fills from the Java byte[]. raw_.buf is kept + // in sync with the (possibly reallocated) buffer. + uint8_t* reserve_body(size_t len) { + if (len > buf_.size()) { + buf_.resize(len); + } + raw_.buf = buf_.data(); + raw_.len = len; + return buf_.data(); + } + + // Adds an interned string proto field carried alongside the raw body and + // interned natively at emit time. Interned fields are rare, so this per-field + // crossing is fine; events without one pay nothing. + void add_interned(uint32_t id, const char* str, uint32_t interned_type_id) { + interned_strs_.emplace_back(str); + PerfettoTeHlProtoFieldCstrInterned field{}; + field.header.type = PERFETTO_TE_HL_PROTO_TYPE_CSTR_INTERNED; + field.header.id = id; + field.interned_type_id = interned_type_id; + interned_.push_back(field); + rebuild(); + } + + // Clears per-event state after the emit consumes it. Cheap on the common path + // (no interned fields): a single store plus an empty check. + void reset_after_emit() { + raw_.len = 0; + if (!interned_.empty()) { + interned_.clear(); + interned_strs_.clear(); + rebuild(); + } + } + + static void delete_raw_body(RawBody* raw_body) { delete raw_body; } + + const PerfettoTeHlExtraProtoFields* get() const { return &proto_; } + + private: + // Rebuilds the NULL-terminated field list (the raw body field, then each + // interned field). Re-derives every pointer from current storage, so it is + // safe after a vector reallocation. + void rebuild() { + fields_.clear(); + fields_.push_back(&raw_.header); + for (size_t i = 0; i < interned_.size(); ++i) { + interned_[i].str = interned_strs_[i].c_str(); + fields_.push_back(&interned_[i].header); + } + fields_.push_back(nullptr); + proto_.fields = fields_.data(); + } + + DISALLOW_COPY_AND_ASSIGN(RawBody); + PerfettoTeHlExtraProtoFields proto_; + PerfettoTeHlProtoFieldRaw raw_; + std::vector<uint8_t> buf_; + std::vector<PerfettoTeHlProtoFieldCstrInterned> interned_; + std::vector<std::string> interned_strs_; + std::vector<PerfettoTeHlProtoField*> fields_; +}; + /** * @brief Represents a debug argument for a trace event. */
diff --git a/src/android_sdk/perfetto_sdk_for_jni/tracing_sdk_unittest.cc b/src/android_sdk/perfetto_sdk_for_jni/tracing_sdk_unittest.cc index d3e3c55..3564962 100644 --- a/src/android_sdk/perfetto_sdk_for_jni/tracing_sdk_unittest.cc +++ b/src/android_sdk/perfetto_sdk_for_jni/tracing_sdk_unittest.cc
@@ -14,6 +14,8 @@ * limitations under the License. */ +#include <cstring> + #include "test/gtest_and_gmock.h" #include "src/android_sdk/perfetto_sdk_for_jni/tracing_sdk.h" @@ -117,25 +119,18 @@ auto tracing_session = StartTracing(); - // In this test we generate a named slice with an additional payload - - sdk_for_jni::DebugArg player_number_extra("player_number"); - player_number_extra.get()->arg_int64.header.type = - PERFETTO_TE_HL_EXTRA_TYPE_DEBUG_ARG_INT64; - player_number_extra.get()->arg_int64.name = player_number_extra.name(); - player_number_extra.get()->arg_int64.value = 42; - - sdk_for_jni::DebugArg player_alive_extra("player_alive"); - player_alive_extra.get()->arg_bool.header.type = - PERFETTO_TE_HL_EXTRA_TYPE_DEBUG_ARG_BOOL; - player_alive_extra.get()->arg_bool.name = player_alive_extra.name(); - player_alive_extra.get()->arg_bool.value = true; + // Debug args / proto fields are now encoded into the body on the Java side and + // handed to native via RawBody, which splices them verbatim onto the event as + // one RAW proto field. Here we hand RawBody the wire bytes of + // `debug_annotations { int_value: 42 }` (TrackEvent field 4, length 2, holding + // DebugAnnotation field 4 = int_value, varint 42). + static const uint8_t kBody[] = {0x22, 0x02, 0x20, 0x2a}; + sdk_for_jni::RawBody body; + std::memcpy(body.reserve_body(sizeof(kBody)), kBody, sizeof(kBody)); sdk_for_jni::Extra extra; extra.push_extra(reinterpret_cast<PerfettoTeHlExtra*>( - &player_number_extra.get()->arg_int64)); - extra.push_extra(reinterpret_cast<PerfettoTeHlExtra*>( - &player_alive_extra.get()->arg_bool)); + const_cast<PerfettoTeHlExtraProtoFields*>(body.get()))); trace_event(PERFETTO_TE_TYPE_SLICE_BEGIN, category.get(), "DrawPlayer", &extra); @@ -153,8 +148,8 @@ } const char* actual = R"(packet { -data { categories: [rendering] names: [DrawPlayer], debug_annotation_names: [player_number, player_alive] } -event { type: 1, debug_annotations: [int: 42, bool: 1] } +data { categories: [rendering] names: [DrawPlayer], debug_annotation_names: [] } +event { type: 1, debug_annotations: [int: 42] } } packet { event { type: 2, debug_annotations: [] }
diff --git a/src/shared_lib/track_event/hl.cc b/src/shared_lib/track_event/hl.cc index 889c411..b1e8446 100644 --- a/src/shared_lib/track_event/hl.cc +++ b/src/shared_lib/track_event/hl.cc
@@ -81,6 +81,11 @@ msg->AppendBytes(field->header.id, field->buf, field->len); break; } + case PERFETTO_TE_HL_PROTO_TYPE_RAW: { + auto field = reinterpret_cast<PerfettoTeHlProtoFieldRaw*>(*p); + msg->AppendRawProtoBytes(field->buf, field->len); + break; + } case PERFETTO_TE_HL_PROTO_TYPE_NESTED: { auto field = reinterpret_cast<PerfettoTeHlProtoFieldNested*>(*p); auto* nested =